Compare commits

..
62 Commits
Author SHA1 Message Date
Gani Georgiev bd76130cd8 [#] added missing import in the api preview for deleting records 2022-08-12 12:20:56 +03:00
Gani Georgiev d60dd13581 [#294] added additional inline serving mime types 2022-08-11 20:09:26 +03:00
Gani Georgiev 19d4fc04c1 fixed UI import preview when replacing ids with missing old fields 2022-08-11 15:59:14 +03:00
Gani Georgiev a7b29c1961 updated dependencies and rebuilt ui/dist 2022-08-11 11:33:01 +03:00
Gani Georgiev 147344546b added custom insertion id regex check 2022-08-11 10:29:01 +03:00
Gani Georgiev ff935a39a1 removed spacing 2022-08-11 08:13:07 +03:00
Gani Georgiev 2cce0b17b0 synced with master 2022-08-10 20:53:13 +03:00
Gani Georgiev 5f25572e95 updated ui/dist 2022-08-10 20:46:45 +03:00
Gani Georgiev dd5c9ccce8 changed btns transition to local 2022-08-10 20:45:32 +03:00
Olle MånssonandOlle Månsson da29141248 [#288] added info about the default 100x100 thumb size in the field tooltip
Co-authored-by: Olle Månsson <olle.mansson@zenseact.com>
2022-08-10 20:40:52 +03:00
Gani Georgiev 7f6049ebd6 updated test db and ui/dist 2022-08-10 20:37:41 +03:00
Gani Georgiev 119e1fb3f2 use fixed ids in the default profiles system collections migration 2022-08-10 19:08:29 +03:00
Gani Georgiev c5091898ae added test to test schema field persistence after import 2022-08-10 17:43:55 +03:00
Gani Georgiev 35bd395e55 removed unused lib 2022-08-10 17:43:08 +03:00
Gani Georgiev d56b8fcb90 updated import popup handling and api preview examples 2022-08-10 16:16:59 +03:00
Gani Georgiev 65b830198b test deleteMissing with schema changes 2022-08-10 13:22:27 +03:00
Gani Georgiev ac0c23ff64 fixed list js example 2022-08-09 17:57:32 +03:00
Gani Georgiev a355c8e8a9 removed duplicated method 2022-08-09 17:20:22 +03:00
Gani Georgiev c346dd69a4 updated ui/dist 2022-08-09 17:17:13 +03:00
Gani Georgiev f8f785d6e3 call transaction Dao events only after commit, added totalPages to the search response and updated the tests 2022-08-09 16:20:39 +03:00
Gani Georgiev 8288da8372 added version number in the footer 2022-08-09 16:16:09 +03:00
Gani Georgiev 8b2b26c196 fixed after hooks 2022-08-08 20:14:46 +03:00
Gani Georgiev 8009d37d24 updated tests 2022-08-08 19:16:33 +03:00
Gani Georgiev 6e9d000426 before updateding test data 2022-08-07 20:58:21 +03:00
Gani Georgiev a426484916 added WithConfig factory to all forms 2022-08-07 15:38:21 +03:00
Gani Georgiev b0ca9b2f1b updated export/import form 2022-08-07 11:14:49 +03:00
Gani Georgiev 956263d1fc updated admin and user upsert forms 2022-08-06 22:16:58 +03:00
Gani Georgiev 51d635bc12 fixed popup hide 2022-08-06 18:59:28 +03:00
Gani Georgiev 5fb45a1864 updated CollectionsImport and CollectionUpsert forms 2022-08-06 18:15:18 +03:00
Gani Georgiev 4e58e7ad6a added ImportPopup 2022-08-06 08:03:34 +03:00
Gani Georgiev 93ab5fbea2 added page export and import 2022-08-05 23:25:16 +03:00
Gani Georgiev f459dd8812 import scaffoldings 2022-08-05 06:00:38 +03:00
Gani Georgiev 95f9d685dc updated base model comments 2022-08-03 16:26:36 +03:00
Gani Georgiev bb00f198bc changed btns transition to local 2022-08-03 10:16:13 +03:00
Gani Georgiev 0e1b9a3897 updated go version constraint for the github action 2022-08-02 21:36:36 +03:00
Gani Georgiev e32cf12908 updated ui/dist 2022-08-02 21:32:36 +03:00
Gani Georgiev c152f99793 fixed datepicker clipped input borders 2022-08-02 21:31:42 +03:00
Gani Georgiev f8b7a40837 switch to stable go 1.19.0 2022-08-02 20:57:03 +03:00
Gani Georgiev a049a37624 updated sdk to ^0.3.0 2022-08-02 17:00:14 +03:00
Gani Georgiev 8268c26d8b fixed README typo 2022-08-01 22:30:14 +03:00
Gani Georgiev 6292e6cc2e unify the prod and dev env and use only relative base path ./ in the Admin UI 2022-08-01 20:55:31 +03:00
Gani Georgiev fbeaabf6aa removed no longer needed ui.DistIndexHTML var 2022-08-01 20:53:06 +03:00
Gani Georgiev 30d1b9f358 refactored the admin ui routes registration for better sub-path deployment support 2022-08-01 20:37:51 +03:00
Gani Georgiev 16fa099685 added default admin CSP 2022-08-01 18:10:18 +03:00
Gani Georgiev bb4bebc724 removed box-shadow for image-preview modals 2022-08-01 18:00:06 +03:00
Gani Georgiev 9d0ea7635b [#204] fixed query string parsing 2022-08-01 14:20:21 +03:00
Yin Shanyang d35134e913 [#250] added armv7 on linux as a build target 2022-08-01 10:29:06 +03:00
Gani Georgiev ce8af46fff unified file field styles 2022-07-31 23:28:47 +03:00
Gani Georgiev 87ecb1114c cleaning up no longer needed ui helper methods 2022-07-31 23:21:55 +03:00
Gani Georgiev c070be2c47 [#238] removed implicit select items grouping 2022-07-31 18:52:51 +03:00
Gani Georgiev 4f0041a128 added dart-sdk to the readme 2022-07-31 13:05:05 +03:00
Gani Georgiev 0ac24af7c9 updated ui/dist 2022-07-31 11:51:06 +03:00
Gani Georgiev 96b2c5fedf added Dart to the api preview examples 2022-07-30 21:04:44 +03:00
Gani Georgiev 4019ca5f00 [#223] change the default prod backend url to relative path to support sub-path deployment 2022-07-30 20:59:09 +03:00
Gani Georgiev 20fe3c8c91 added debug log for the invalid uploaded file(s) 2022-07-30 14:11:08 +03:00
Gani Georgiev d87a5e544c updated admin ui dependencies 2022-07-30 08:02:41 +03:00
Gani Georgiev bb527be493 fixed panic on expanding existing byt non-relation type field 2022-07-30 07:58:42 +03:00
Gani Georgiev 9e3b230c8e added debug log for established realtime connection 2022-07-28 08:26:05 +03:00
Gani Georgiev 686198a22e normalize number filter literals
Always cast number literals to provide consistent eq/neq behavior when combined with COALESCE, because '1' = 1 is TRUE but COALESCE('1', '') = COALESCE(1, '') will result to FALSE.
2022-07-28 05:23:58 +03:00
Gani Georgiev 086b992c7d [#228] added target=_blank to the email links 2022-07-26 15:17:10 +03:00
Takeshi Sato 88d8cec3d9 [#207] use read-only scopes for the GitHub OAuth2 provider 2022-07-24 18:04:53 +03:00
Gani Georgiev 7926501649 updated comments and added CSP header check in the tests 2022-07-21 17:22:31 +03:00
161 changed files with 6350 additions and 1926 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v3
with:
go-version: '1.19.0-beta.1'
go-version: '>=1.19.0'
# This step usually is not needed because the /ui/dist is pregenerated locally
# but its here to ensure that each release embeds the latest admin ui artifacts.
+10 -3
View File
@@ -20,13 +20,20 @@ builds:
goarch:
- amd64
- arm64
- arm
goarm:
- 7
ignore:
- goos: windows
goarch: arm
- goos: darwin
goarch: arm
release:
draft: true
archives:
-
format: zip
- format: zip
files:
- LICENSE*
- CHANGELOG*
@@ -35,7 +42,7 @@ checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
name_template: '{{ incpatch .Version }}-next'
changelog:
sort: asc
+2 -2
View File
@@ -29,7 +29,7 @@
The easiest way to interact with the API is to use one of the official SDK clients:
- **JavaScript - [pocketbase/js-sdk](https://github.com/pocketbase/js-sdk)** (_browser and node_)
- **Dart** - _soon_
- **Dart - [pocketbase/dart-sdk](https://github.com/pocketbase/dart-sdk)** (_web, mobile, desktop_)
## Overview
@@ -91,7 +91,7 @@ Running/building the application is the same as for any other Go program, aka. j
**PocketBase embeds SQLite, but doesn't require CGO.**
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` enviroment variable to `1` or `0` respectively.
If CGO is enabled, it will use [mattn/go-sqlite3](https://pkg.go.dev/github.com/mattn/go-sqlite3) driver, otherwise - [modernc.org/sqlite](https://pkg.go.dev/modernc.org/sqlite). You can enable and disable CGO by setting the `CGO_ENABLED` environment variable to `1` or `0` respectively.
Enable CGO only if you really need to squeeze the read/write query performance at the expense of complicating cross compilation.
+25 -13
View File
@@ -18,6 +18,8 @@ import (
"github.com/spf13/cast"
)
const trailedAdminPath = "/_/"
// InitApi creates a configured echo instance with registered
// system and app specific routes and middlewares.
func InitApi(app core.App) (*echo.Echo, error) {
@@ -25,7 +27,12 @@ func InitApi(app core.App) (*echo.Echo, error) {
e.Debug = app.IsDebug()
// default middlewares
e.Pre(middleware.RemoveTrailingSlash())
e.Pre(middleware.RemoveTrailingSlashWithConfig(middleware.RemoveTrailingSlashConfig{
Skipper: func(c echo.Context) bool {
// ignore Admin UI route(s)
return strings.HasPrefix(c.Request().URL.Path, trailedAdminPath)
},
}))
e.Use(middleware.Recover())
e.Use(middleware.Secure())
e.Use(LoadAuthContext(app))
@@ -128,21 +135,20 @@ func StaticDirectoryHandler(fileSystem fs.FS, disablePathUnescaping bool) echo.H
// bindStaticAdminUI registers the endpoints that serves the static admin UI.
func bindStaticAdminUI(app core.App, e *echo.Echo) error {
// serves /ui/dist/index.html file
// (explicit route is used to avoid conflicts with `RemoveTrailingSlash` middleware)
e.FileFS(
"/_",
"index.html",
ui.DistIndexHTML,
middleware.Gzip(),
installerRedirect(app),
// redirect to trailing slash to ensure that relative urls will still work properly
e.GET(
strings.TrimRight(trailedAdminPath, "/"),
func(c echo.Context) error {
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath)
},
)
// serves static files from the /ui/dist directory
// (similar to echo.StaticFS but with gzip middleware enabled)
e.GET(
"/_/*",
StaticDirectoryHandler(ui.DistDirFS, false),
trailedAdminPath+"*",
echo.StaticDirectoryHandler(ui.DistDirFS, false),
installerRedirect(app),
middleware.Gzip(),
)
@@ -175,6 +181,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// skip redirect checks for non-root level index.html requests
path := c.Request().URL.Path
if path != trailedAdminPath && path != trailedAdminPath+"index.html" {
return next(c)
}
// load into cache (if not already)
if !app.Cache().Has(totalAdminsCacheKey) {
if err := updateTotalAdminsCache(app); err != nil {
@@ -188,12 +200,12 @@ func installerRedirect(app core.App) echo.MiddlewareFunc {
if totalAdmins == 0 && !hasInstallerParam {
// redirect to the installer page
return c.Redirect(http.StatusTemporaryRedirect, "/_/?installer#")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"?installer#")
}
if totalAdmins != 0 && hasInstallerParam {
// redirect to the home page
return c.Redirect(http.StatusTemporaryRedirect, "/_/#/")
return c.Redirect(http.StatusTemporaryRedirect, trailedAdminPath+"#/")
}
return next(c)
+36
View File
@@ -21,6 +21,7 @@ func BindCollectionApi(app core.App, rg *echo.Group) {
subGroup.GET("/:collection", api.view)
subGroup.PATCH("/:collection", api.update)
subGroup.DELETE("/:collection", api.delete)
subGroup.PUT("/import", api.bulkImport)
}
type collectionApi struct {
@@ -167,3 +168,38 @@ func (api *collectionApi) delete(c echo.Context) error {
return handlerErr
}
func (api *collectionApi) bulkImport(c echo.Context) error {
form := forms.NewCollectionsImport(api.app)
// load request data
if err := c.Bind(form); err != nil {
return rest.NewBadRequestError("Failed to load the submitted data due to invalid formatting.", err)
}
event := &core.CollectionsImportEvent{
HttpContext: c,
Collections: form.Collections,
}
// import collections
submitErr := form.Submit(func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
return api.app.OnCollectionsBeforeImportRequest().Trigger(event, func(e *core.CollectionsImportEvent) error {
form.Collections = e.Collections // ensures that the form always has the latest changes
if err := next(); err != nil {
return rest.NewBadRequestError("Failed to import the submitted collections.", err)
}
return e.HttpContext.NoContent(http.StatusNoContent)
})
}
})
if submitErr == nil {
api.app.OnCollectionsAfterImportRequest().Trigger(event)
}
return submitErr
}
+265
View File
@@ -5,6 +5,8 @@ import (
"strings"
"testing"
"github.com/labstack/echo/v5"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
@@ -440,3 +442,266 @@ func TestCollectionUpdate(t *testing.T) {
scenario.Test(t)
}
}
func TestCollectionImport(t *testing.T) {
scenarios := []tests.ApiScenario{
{
Name: "unauthorized",
Method: http.MethodPut,
Url: "/api/collections/import",
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as user",
Method: http.MethodPut,
Url: "/api/collections/import",
RequestHeaders: map[string]string{
"Authorization": "User eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjRkMDE5N2NjLTJiNGEtM2Y4My1hMjZiLWQ3N2JjODQyM2QzYyIsInR5cGUiOiJ1c2VyIiwiZXhwIjoxODkzNDc0MDAwfQ.Wq5ac1q1f5WntIzEngXk22ydMj-eFgvfSRg7dhmPKic",
},
ExpectedStatus: 401,
ExpectedContent: []string{`"data":{}`},
},
{
Name: "authorized as admin + empty collections",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{"collections":[]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"validation_required"`,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + trying to delete system collections",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{"deleteMissing": true, "collections":[{"name": "test123"}]}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"collections_import_failure"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + collections validator failure",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{"name": "import2"}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"data":{`,
`"collections":{"code":"collections_import_failure"`,
},
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeCreate": 2,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 5 {
t.Fatalf("Expected %d collections, got %d", 5, len(collections))
}
},
},
{
Name: "authorized as admin + successful collections save",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"collections":[
{
"name": "import1",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
},
{
"name": "import2",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsBeforeImportRequest": 1,
"OnCollectionsAfterImportRequest": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 7 {
t.Fatalf("Expected %d collections, got %d", 7, len(collections))
}
},
},
{
Name: "authorized as admin + successful collections save and old non-system collections deletion",
Method: http.MethodPut,
Url: "/api/collections/import",
Body: strings.NewReader(`{
"deleteMissing": true,
"collections":[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "new_import",
"schema": [
{
"id": "koih1lqx",
"name": "test",
"type": "text"
}
]
}
]
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 204,
ExpectedEvents: map[string]int{
"OnCollectionsAfterImportRequest": 1,
"OnCollectionsBeforeImportRequest": 1,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
AfterFunc: func(t *testing.T, app *tests.TestApp, e *echo.Echo) {
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != 3 {
t.Fatalf("Expected %d collections, got %d", 3, len(collections))
}
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
+12 -4
View File
@@ -58,6 +58,10 @@ func (api *realtimeApi) connect(c echo.Context) error {
return err
}
if api.app.IsDebug() {
log.Printf("Realtime connection establisehd: %s\n", client.Id())
}
// signalize established connection (aka. fire "connect" message)
fmt.Fprint(c.Response(), "id:"+client.Id()+"\n")
fmt.Fprint(c.Response(), "event:PB_CONNECT\n")
@@ -206,13 +210,17 @@ func (api *realtimeApi) bindEvents() {
return nil
})
api.app.OnRecordAfterCreateRequest().Add(func(e *core.RecordCreateEvent) error {
api.broadcastRecord("create", e.Record)
api.app.OnModelAfterCreate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("create", record)
}
return nil
})
api.app.OnRecordAfterUpdateRequest().Add(func(e *core.RecordUpdateEvent) error {
api.broadcastRecord("update", e.Record)
api.app.OnModelAfterUpdate().Add(func(e *core.ModelEvent) error {
if record, ok := e.Model.(*models.Record); ok {
api.broadcastRecord("update", record)
}
return nil
})
+56
View File
@@ -750,6 +750,62 @@ func TestRecordCreate(t *testing.T) {
"OnModelAfterCreate": 1,
},
},
{
Name: "invalid custom insertion id (less than 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "12345678901234",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"id":{"code":"validation_length_invalid"`,
},
},
{
Name: "invalid custom insertion id (more than 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "1234567890123456",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 400,
ExpectedContent: []string{
`"id":{"code":"validation_length_invalid"`,
},
},
{
Name: "valid custom insertion id (exactly 15 chars)",
Method: http.MethodPost,
Url: "/api/collections/demo3/records",
Body: strings.NewReader(`{
"id": "123456789012345",
"title": "test"
}`),
RequestHeaders: map[string]string{
"Authorization": "Admin eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjJiNGE5N2NjLTNmODMtNGQwMS1hMjZiLTNkNzdiYzg0MmQzYyIsInR5cGUiOiJhZG1pbiIsImV4cCI6MTg3MzQ2Mjc5Mn0.AtRtXR6FHBrCUGkj5OffhmxLbSZaQ4L_Qgw4gfoHyfo",
},
ExpectedStatus: 200,
ExpectedContent: []string{
`"id":"123456789012345"`,
`"title":"test"`,
},
ExpectedEvents: map[string]int{
"OnRecordBeforeCreateRequest": 1,
"OnRecordAfterCreateRequest": 1,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
},
},
{
Name: "submit via multipart form data",
Method: http.MethodPost,
+168 -4
View File
@@ -1,12 +1,21 @@
package cmd
import (
"encoding/json"
"fmt"
"log"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/migrations/logs"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/pocketbase/pocketbase/tools/migrate"
"github.com/spf13/cobra"
)
@@ -15,18 +24,40 @@ import (
func NewMigrateCommand(app core.App) *cobra.Command {
desc := `
Supported arguments are:
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create folder name - creates new migration template file.
- up - runs all available migrations.
- down [number] - reverts the last [number] applied migrations.
- create name [folder] - creates new migration template file.
- collections [folder] - (Experimental) creates new migration file with the most recent local collections configuration.
`
var databaseFlag string
command := &cobra.Command{
Use: "migrate",
Short: "Executes DB migration scripts",
ValidArgs: []string{"up", "down", "create"},
ValidArgs: []string{"up", "down", "create", "collections"},
Long: desc,
Run: func(command *cobra.Command, args []string) {
cmd := ""
if len(args) > 0 {
cmd = args[0]
}
// additional commands
// ---
if cmd == "create" {
if err := migrateCreateHandler(defaultMigrateCreateTemplate, args[1:]); err != nil {
log.Fatal(err)
}
return
}
if cmd == "collections" {
if err := migrateCollectionsHandler(app, args[1:]); err != nil {
log.Fatal(err)
}
return
}
// ---
// normalize
if databaseFlag != "logs" {
databaseFlag = "db"
@@ -75,3 +106,136 @@ func migrationsConnectionsMap(app core.App) map[string]migrationsConnection {
},
}
}
// -------------------------------------------------------------------
// migrate create
// -------------------------------------------------------------------
const defaultMigrateCreateTemplate = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
func migrateCreateHandler(template string, args []string) error {
if len(args) < 1 {
return fmt.Errorf("Missing migration file name")
}
name := args[0]
var dir string
if len(args) == 2 {
dir = args[1]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(template), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
}
// -------------------------------------------------------------------
// migrate collections
// -------------------------------------------------------------------
const collectionsMigrateCreateTemplate = `package migrations
import (
"encoding/json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/daos"
m "github.com/pocketbase/pocketbase/migrations"
"github.com/pocketbase/pocketbase/models"
)
// Auto generated migration with the most recent collections configuration.
func init() {
m.Register(func(db dbx.Builder) error {
jsonData := ` + "`" + `%s` + "`" + `
collections := []*models.Collection{}
if err := json.Unmarshal([]byte(jsonData), &collections); err != nil {
return err
}
return daos.New(db).ImportCollections(collections, true, nil)
}, func(db dbx.Builder) error {
// no revert since the configuration on the environment, on which
// the migration was executed, could have changed via the UI/API
return nil
})
}
`
func migrateCollectionsHandler(app core.App, args []string) error {
createArgs := []string{"collections_snapshot"}
createArgs = append(createArgs, args...)
dao := daos.New(app.DB())
collections := []*models.Collection{}
if err := dao.CollectionQuery().OrderBy("created ASC").All(&collections); err != nil {
return fmt.Errorf("Failed to fetch migrations list: %v", err)
}
serialized, err := json.MarshalIndent(collections, "\t\t", "\t")
if err != nil {
return fmt.Errorf("Failed to serialize collections list: %v", err)
}
return migrateCreateHandler(
fmt.Sprintf(collectionsMigrateCreateTemplate, string(serialized)),
createArgs,
)
}
+2 -2
View File
@@ -28,7 +28,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command := &cobra.Command{
Use: "serve",
Short: "Starts the web server (default to localhost:8090)",
Short: "Starts the web server (default to 127.0.0.1:8090)",
Run: func(command *cobra.Command, args []string) {
// ensure that the latest migrations are applied before starting the server
if err := runMigrations(app); err != nil {
@@ -123,7 +123,7 @@ func NewServeCommand(app core.App, showStartBanner bool) *cobra.Command {
command.PersistentFlags().StringVar(
&httpAddr,
"http",
"localhost:8090",
"127.0.0.1:8090",
"api HTTP server address",
)
+28 -17
View File
@@ -119,7 +119,7 @@ type App interface {
// sending a password reset email to an admin.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeAdminResetPasswordSend() *hook.Hook[*MailerAdminEvent]
// OnMailerAfterAdminResetPasswordSend hook is triggered after
@@ -130,7 +130,7 @@ type App interface {
// sending a password reset email to a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserResetPasswordSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserResetPasswordSend hook is triggered after
@@ -141,7 +141,7 @@ type App interface {
// sending a verification email to a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserVerificationSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserVerificationSend hook is triggered after a user
@@ -152,7 +152,7 @@ type App interface {
// sending a confirmation new address email to a a user.
//
// Could be used to send your own custom email template if
// hook.StopPropagation is returned in one of its listeners.
// [hook.StopPropagation] is returned in one of its listeners.
OnMailerBeforeUserChangeEmailSend() *hook.Hook[*MailerUserEvent]
// OnMailerAfterUserChangeEmailSend hook is triggered after a user
@@ -192,7 +192,7 @@ type App interface {
//
// Could be used to additionally validate the request data or
// implement completely different persistence behavior
// (returning hook.StopPropagation).
// (returning [hook.StopPropagation]).
OnSettingsBeforeUpdateRequest() *hook.Hook[*SettingsUpdateEvent]
// OnSettingsAfterUpdateRequest hook is triggered after each
@@ -227,7 +227,7 @@ type App interface {
// Admin create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnAdminBeforeCreateRequest() *hook.Hook[*AdminCreateEvent]
// OnAdminAfterCreateRequest hook is triggered after each
@@ -238,7 +238,7 @@ type App interface {
// Admin update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnAdminBeforeUpdateRequest() *hook.Hook[*AdminUpdateEvent]
// OnAdminAfterUpdateRequest hook is triggered after each
@@ -249,7 +249,7 @@ type App interface {
// Admin delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnAdminBeforeDeleteRequest() *hook.Hook[*AdminDeleteEvent]
// OnAdminAfterDeleteRequest hook is triggered after each
@@ -281,7 +281,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnUserBeforeCreateRequest() *hook.Hook[*UserCreateEvent]
// OnUserAfterCreateRequest hook is triggered after each
@@ -292,7 +292,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnUserBeforeUpdateRequest() *hook.Hook[*UserUpdateEvent]
// OnUserAfterUpdateRequest hook is triggered after each
@@ -303,7 +303,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnUserBeforeDeleteRequest() *hook.Hook[*UserDeleteEvent]
// OnUserAfterDeleteRequest hook is triggered after each
@@ -346,7 +346,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeCreateRequest() *hook.Hook[*RecordCreateEvent]
// OnRecordAfterCreateRequest hook is triggered after each
@@ -357,7 +357,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnRecordBeforeUpdateRequest() *hook.Hook[*RecordUpdateEvent]
// OnRecordAfterUpdateRequest hook is triggered after each
@@ -368,7 +368,7 @@ type App interface {
// delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnRecordBeforeDeleteRequest() *hook.Hook[*RecordDeleteEvent]
// OnRecordAfterDeleteRequest hook is triggered after each
@@ -393,7 +393,7 @@ type App interface {
// create request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnCollectionBeforeCreateRequest() *hook.Hook[*CollectionCreateEvent]
// OnCollectionAfterCreateRequest hook is triggered after each
@@ -404,7 +404,7 @@ type App interface {
// update request (after request data load and before model persistence).
//
// Could be used to additionally validate the request data or implement
// completely different persistence behavior (returning hook.StopPropagation).
// completely different persistence behavior (returning [hook.StopPropagation]).
OnCollectionBeforeUpdateRequest() *hook.Hook[*CollectionUpdateEvent]
// OnCollectionAfterUpdateRequest hook is triggered after each
@@ -415,10 +415,21 @@ type App interface {
// Collection delete request (after model load and before actual deletion).
//
// Could be used to additionally validate the request data or implement
// completely different delete behavior (returning hook.StopPropagation).
// completely different delete behavior (returning [hook.StopPropagation]).
OnCollectionBeforeDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionAfterDeleteRequest hook is triggered after each
// successful API Collection delete request.
OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDeleteEvent]
// OnCollectionsBeforeImportRequest hook is triggered before each API
// collections import request (after request data load and before the actual import).
//
// Could be used to additionally validate the imported collections or
// to implement completely different import behavior (returning [hook.StopPropagation]).
OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent]
// OnCollectionsAfterImportRequest hook is triggered after each
// successful API collections import request.
OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent]
}
+28 -16
View File
@@ -109,14 +109,16 @@ type BaseApp struct {
onRecordAfterDeleteRequest *hook.Hook[*RecordDeleteEvent]
// collection api event hooks
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionsListRequest *hook.Hook[*CollectionsListEvent]
onCollectionViewRequest *hook.Hook[*CollectionViewEvent]
onCollectionBeforeCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionAfterCreateRequest *hook.Hook[*CollectionCreateEvent]
onCollectionBeforeUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionAfterUpdateRequest *hook.Hook[*CollectionUpdateEvent]
onCollectionBeforeDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionAfterDeleteRequest *hook.Hook[*CollectionDeleteEvent]
onCollectionsBeforeImportRequest *hook.Hook[*CollectionsImportEvent]
onCollectionsAfterImportRequest *hook.Hook[*CollectionsImportEvent]
}
// NewBaseApp creates and returns a new BaseApp instance
@@ -201,14 +203,16 @@ func NewBaseApp(dataDir string, encryptionEnv string, isDebug bool) *BaseApp {
onRecordAfterDeleteRequest: &hook.Hook[*RecordDeleteEvent]{},
// collection API event hooks
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsListRequest: &hook.Hook[*CollectionsListEvent]{},
onCollectionViewRequest: &hook.Hook[*CollectionViewEvent]{},
onCollectionBeforeCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionAfterCreateRequest: &hook.Hook[*CollectionCreateEvent]{},
onCollectionBeforeUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionAfterUpdateRequest: &hook.Hook[*CollectionUpdateEvent]{},
onCollectionBeforeDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionAfterDeleteRequest: &hook.Hook[*CollectionDeleteEvent]{},
onCollectionsBeforeImportRequest: &hook.Hook[*CollectionsImportEvent]{},
onCollectionsAfterImportRequest: &hook.Hook[*CollectionsImportEvent]{},
}
app.registerDefaultHooks()
@@ -687,6 +691,14 @@ func (app *BaseApp) OnCollectionAfterDeleteRequest() *hook.Hook[*CollectionDelet
return app.onCollectionAfterDeleteRequest
}
func (app *BaseApp) OnCollectionsBeforeImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsBeforeImportRequest
}
func (app *BaseApp) OnCollectionsAfterImportRequest() *hook.Hook[*CollectionsImportEvent] {
return app.onCollectionsAfterImportRequest
}
// -------------------------------------------------------------------
// Helpers
// -------------------------------------------------------------------
+5
View File
@@ -216,6 +216,11 @@ type CollectionDeleteEvent struct {
Collection *models.Collection
}
type CollectionsImportEvent struct {
HttpContext echo.Context
Collections []*models.Collection
}
// -------------------------------------------------------------------
// File API events data
// -------------------------------------------------------------------
+61 -26
View File
@@ -49,6 +49,12 @@ func (dao *Dao) FindById(m models.Model, id string) error {
return dao.ModelQuery(m).Where(dbx.HashExp{"id": id}).Limit(1).One(m)
}
type afterCallGroup struct {
Action string
EventDao *Dao
Model models.Model
}
// RunInTransaction wraps fn into a transaction.
//
// It is safe to nest RunInTransaction calls.
@@ -59,45 +65,61 @@ func (dao *Dao) RunInTransaction(fn func(txDao *Dao) error) error {
// so execute the function within the current transaction
return fn(dao)
case *dbx.DB:
return txOrDB.Transactional(func(tx *dbx.Tx) error {
afterCalls := []afterCallGroup{}
txError := txOrDB.Transactional(func(tx *dbx.Tx) error {
txDao := New(tx)
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeCreateFunc != nil {
if dao.BeforeCreateFunc != nil {
txDao.BeforeCreateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeCreateFunc(eventDao, m)
}
return nil
}
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(eventDao, m)
}
}
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeUpdateFunc != nil {
if dao.BeforeUpdateFunc != nil {
txDao.BeforeUpdateFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeUpdateFunc(eventDao, m)
}
return nil
}
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterUpdateFunc != nil {
dao.AfterUpdateFunc(eventDao, m)
}
}
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
if dao.BeforeDeleteFunc != nil {
if dao.BeforeDeleteFunc != nil {
txDao.BeforeDeleteFunc = func(eventDao *Dao, m models.Model) error {
return dao.BeforeDeleteFunc(eventDao, m)
}
return nil
}
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
if dao.AfterDeleteFunc != nil {
dao.AfterDeleteFunc(eventDao, m)
if dao.AfterCreateFunc != nil {
txDao.AfterCreateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"create", eventDao, m})
}
}
if dao.AfterUpdateFunc != nil {
txDao.AfterUpdateFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"update", eventDao, m})
}
}
if dao.AfterDeleteFunc != nil {
txDao.AfterDeleteFunc = func(eventDao *Dao, m models.Model) {
afterCalls = append(afterCalls, afterCallGroup{"delete", eventDao, m})
}
}
return fn(txDao)
})
if txError == nil {
// execute after event calls on successful transaction
for _, call := range afterCalls {
switch call.Action {
case "create":
dao.AfterCreateFunc(call.EventDao, call.Model)
case "update":
dao.AfterUpdateFunc(call.EventDao, call.Model)
case "delete":
dao.AfterDeleteFunc(call.EventDao, call.Model)
}
}
}
return txError
}
return errors.New("Failed to start transaction (unknown dao.db)")
@@ -128,11 +150,11 @@ func (dao *Dao) Delete(m models.Model) error {
// Save upserts (update or create if primary key is not set) the provided model.
func (dao *Dao) Save(m models.Model) error {
if m.HasId() {
return dao.update(m)
if m.IsNew() {
return dao.create(m)
}
return dao.create(m)
return dao.update(m)
}
func (dao *Dao) update(m models.Model) error {
@@ -140,6 +162,10 @@ func (dao *Dao) update(m models.Model) error {
return errors.New("ID is not set")
}
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
m.RefreshUpdated()
if dao.BeforeUpdateFunc != nil {
@@ -179,6 +205,9 @@ func (dao *Dao) create(m models.Model) error {
m.RefreshId()
}
// mark the model as "new" since the model now always has an ID
m.MarkAsNew()
if m.GetCreated().IsZero() {
m.RefreshCreated()
}
@@ -195,6 +224,9 @@ func (dao *Dao) create(m models.Model) error {
if v, ok := any(m).(models.ColumnValueMapper); ok {
dataMap := v.ColumnValueMap()
if _, ok := dataMap["id"]; !ok {
dataMap["id"] = m.GetId()
}
_, err := dao.db.Insert(m.TableName(), dataMap).Execute()
if err != nil {
@@ -206,6 +238,9 @@ func (dao *Dao) create(m models.Model) error {
}
}
// clears the "new" model flag
m.UnmarkAsNew()
if dao.AfterCreateFunc != nil {
dao.AfterCreateFunc(dao, m)
}
+269 -10
View File
@@ -158,6 +158,33 @@ func TestDaoSaveCreate(t *testing.T) {
}
}
func TestDaoSaveWithInsertId(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &models.Admin{}
model.Id = "test"
model.Email = "test_new@example.com"
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
// refresh
model, _ = testApp.Dao().FindAdminById("test")
if model == nil {
t.Fatal("Failed to find admin with id 'test'")
}
expectedHooks := []string{"OnModelBeforeCreate", "OnModelAfterCreate"}
for _, h := range expectedHooks {
if v, ok := testApp.EventCalls[h]; !ok || v != 1 {
t.Fatalf("Expected event %s to be called exactly one time, got %d", h, v)
}
}
}
func TestDaoSaveUpdate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -184,6 +211,61 @@ func TestDaoSaveUpdate(t *testing.T) {
}
}
type dummyColumnValueMapper struct {
models.Admin
}
func (a *dummyColumnValueMapper) ColumnValueMap() map[string]any {
return map[string]any{
"email": a.Email,
"passwordHash": a.PasswordHash,
"tokenKey": "custom_token_key",
}
}
func TestDaoSaveWithColumnValueMapper(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
model := &dummyColumnValueMapper{}
model.Id = "test_mapped_id" // explicitly set an id
model.Email = "test_mapped_create@example.com"
model.TokenKey = "test_unmapped_token_key" // not used in the map
model.SetPassword("123456")
model.MarkAsNew()
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
createdModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if createdModel == nil {
t.Fatal("[create] Failed to find model with id 'test_mapped_id'")
}
if createdModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if createdModel.TokenKey != "custom_token_key" {
t.Fatalf("Expected model with tokenKey %q, got %q", "custom_token_key", createdModel.TokenKey)
}
model.Email = "test_mapped_update@example.com"
model.Avatar = 9 // not mapped and expect to be ignored
if err := testApp.Dao().Save(model); err != nil {
t.Fatal(err)
}
updatedModel, _ := testApp.Dao().FindAdminById("test_mapped_id")
if updatedModel == nil {
t.Fatal("[update] Failed to find model with id 'test_mapped_id'")
}
if updatedModel.Email != model.Email {
t.Fatalf("Expected model with email %q, got %q", model.Email, createdModel.Email)
}
if updatedModel.Avatar != 0 {
t.Fatalf("Expected model avatar 0, got %v", updatedModel.Avatar)
}
}
func TestDaoDelete(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
@@ -211,35 +293,212 @@ func TestDaoBeforeHooksError(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
testApp.Dao().BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_create")
}
testApp.Dao().BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_update")
}
testApp.Dao().BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
return errors.New("before_delete")
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
// try to create
// test create error
// ---
newModel := &models.Admin{}
newModel.Email = "test_new@example.com"
if err := testApp.Dao().Save(newModel); err.Error() != "before_create" {
if err := baseDao.Save(newModel); err.Error() != "before_create" {
t.Fatalf("Expected before_create error, got %v", err)
}
// try to update
// test update error
// ---
if err := testApp.Dao().Save(existingModel); err.Error() != "before_update" {
if err := baseDao.Save(existingModel); err.Error() != "before_update" {
t.Fatalf("Expected before_update error, got %v", err)
}
// try to delete
// test delete error
// ---
if err := testApp.Dao().Delete(existingModel); err.Error() != "before_delete" {
if err := baseDao.Delete(existingModel); err.Error() != "before_delete" {
t.Fatalf("Expected before_delete error, got %v", err)
}
}
func TestDaoTransactionHooksCallsOnFailure(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return errors.New("test_tx_error")
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 0 {
t.Fatalf("Expected afterCreateFuncCalls to be called 0 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 0 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 0 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 0 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 0 times, got %d", afterDeleteFuncCalls)
}
}
func TestDaoTransactionHooksCallsOnSuccess(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
beforeCreateFuncCalls := 0
beforeUpdateFuncCalls := 0
beforeDeleteFuncCalls := 0
afterCreateFuncCalls := 0
afterUpdateFuncCalls := 0
afterDeleteFuncCalls := 0
baseDao := testApp.Dao()
baseDao.BeforeCreateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeCreateFuncCalls++
return nil
}
baseDao.BeforeUpdateFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeUpdateFuncCalls++
return nil
}
baseDao.BeforeDeleteFunc = func(eventDao *daos.Dao, m models.Model) error {
beforeDeleteFuncCalls++
return nil
}
baseDao.AfterCreateFunc = func(eventDao *daos.Dao, m models.Model) {
afterCreateFuncCalls++
}
baseDao.AfterUpdateFunc = func(eventDao *daos.Dao, m models.Model) {
afterUpdateFuncCalls++
}
baseDao.AfterDeleteFunc = func(eventDao *daos.Dao, m models.Model) {
afterDeleteFuncCalls++
}
existingModel, _ := testApp.Dao().FindAdminByEmail("test@example.com")
baseDao.RunInTransaction(func(txDao1 *daos.Dao) error {
return txDao1.RunInTransaction(func(txDao2 *daos.Dao) error {
// test create
// ---
newModel := &models.Admin{}
newModel.Email = "test_new1@example.com"
newModel.SetPassword("123456")
if err := txDao2.Save(newModel); err != nil {
t.Fatal(err)
}
// test update (twice)
// ---
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
if err := txDao2.Save(existingModel); err != nil {
t.Fatal(err)
}
// test delete
// ---
if err := txDao2.Delete(existingModel); err != nil {
t.Fatal(err)
}
return nil
})
})
if beforeCreateFuncCalls != 1 {
t.Fatalf("Expected beforeCreateFuncCalls to be called 1 times, got %d", beforeCreateFuncCalls)
}
if beforeUpdateFuncCalls != 2 {
t.Fatalf("Expected beforeUpdateFuncCalls to be called 2 times, got %d", beforeUpdateFuncCalls)
}
if beforeDeleteFuncCalls != 1 {
t.Fatalf("Expected beforeDeleteFuncCalls to be called 1 times, got %d", beforeDeleteFuncCalls)
}
if afterCreateFuncCalls != 1 {
t.Fatalf("Expected afterCreateFuncCalls to be called 1 times, got %d", afterCreateFuncCalls)
}
if afterUpdateFuncCalls != 2 {
t.Fatalf("Expected afterUpdateFuncCalls to be called 2 times, got %d", afterUpdateFuncCalls)
}
if afterDeleteFuncCalls != 1 {
t.Fatalf("Expected afterDeleteFuncCalls to be called 1 times, got %d", afterDeleteFuncCalls)
}
}
+115 -3
View File
@@ -113,7 +113,7 @@ func (dao *Dao) FindCollectionReferences(collection *models.Collection, excludeI
// - is referenced as part of a relation field in another collection
func (dao *Dao) DeleteCollection(collection *models.Collection) error {
if collection.System {
return errors.New("System collections cannot be deleted.")
return fmt.Errorf("System collection %q cannot be deleted.", collection.Name)
}
// ensure that there aren't any existing references.
@@ -123,7 +123,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
return err
}
if total := len(result); total > 0 {
return fmt.Errorf("The collection has external relation field references (%d).", total)
return fmt.Errorf("The collection %q has external relation field references (%d).", collection.Name, total)
}
return dao.RunInTransaction(func(txDao *Dao) error {
@@ -141,7 +141,7 @@ func (dao *Dao) DeleteCollection(collection *models.Collection) error {
func (dao *Dao) SaveCollection(collection *models.Collection) error {
var oldCollection *models.Collection
if collection.HasId() {
if !collection.IsNew() {
// get the existing collection state to compare with the new one
// note: the select is outside of the transaction to prevent SQLITE_LOCKED error when mixing read&write in a single transaction
var findErr error
@@ -161,3 +161,115 @@ func (dao *Dao) SaveCollection(collection *models.Collection) error {
return txDao.SyncRecordTableSchema(collection, oldCollection)
})
}
// ImportCollections imports the provided collections list within a single transaction.
//
// NB1! If deleteMissing is set, all local collections and schema fields, that are not present
// in the imported configuration, WILL BE DELETED (including their related records data).
//
// NB2! This method doesn't perform validations on the imported collections data!
// If you need validations, use [forms.CollectionsImport].
func (dao *Dao) ImportCollections(
importedCollections []*models.Collection,
deleteMissing bool,
beforeRecordsSync func(txDao *Dao, mappedImported, mappedExisting map[string]*models.Collection) error,
) error {
if len(importedCollections) == 0 {
return errors.New("No collections to import")
}
return dao.RunInTransaction(func(txDao *Dao) error {
existingCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&existingCollections); err != nil {
return err
}
mappedExisting := make(map[string]*models.Collection, len(existingCollections))
for _, existing := range existingCollections {
mappedExisting[existing.GetId()] = existing
}
mappedImported := make(map[string]*models.Collection, len(importedCollections))
for _, imported := range importedCollections {
// generate id if not set
if !imported.HasId() {
imported.MarkAsNew()
imported.RefreshId()
}
if existing, ok := mappedExisting[imported.GetId()]; ok {
// preserve original created date
if !existing.Created.IsZero() {
imported.Created = existing.Created
}
// extend existing schema
if !deleteMissing {
schema, _ := existing.Schema.Clone()
for _, f := range imported.Schema.Fields() {
schema.AddField(f) // add or replace
}
imported.Schema = *schema
}
} else {
imported.MarkAsNew()
}
mappedImported[imported.GetId()] = imported
}
// delete old collections not available in the new configuration
// (before saving the imports in case a deleted collection name is being reused)
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if existing.System {
return fmt.Errorf("System collection %q cannot be deleted.", existing.Name)
}
// delete the collection
if err := txDao.Delete(existing); err != nil {
return err
}
}
}
// upsert imported collections
for _, imported := range importedCollections {
if err := txDao.Save(imported); err != nil {
return err
}
}
if beforeRecordsSync != nil {
if err := beforeRecordsSync(txDao, mappedImported, mappedExisting); err != nil {
return err
}
}
// delete the record tables of the deleted collections
if deleteMissing {
for _, existing := range existingCollections {
if mappedImported[existing.GetId()] != nil {
continue // exist
}
if err := txDao.DeleteTable(existing.Name); err != nil {
return err
}
}
}
// sync the upserted collections with the related records table
for _, imported := range importedCollections {
existing := mappedExisting[imported.GetId()]
if err := txDao.SyncRecordTableSchema(imported, existing); err != nil {
return err
}
}
return nil
})
}
+314
View File
@@ -1,8 +1,11 @@
package daos_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
@@ -250,3 +253,314 @@ func TestSaveCollectionUpdate(t *testing.T) {
}
}
}
func TestImportCollections(t *testing.T) {
scenarios := []struct {
name string
jsonData string
deleteMissing bool
beforeRecordsSync func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error
expectError bool
expectCollectionsCount int
afterTestFunc func(testApp *tests.TestApp, resultCollections []*models.Collection)
}{
{
name: "empty collections",
jsonData: `[]`,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "check db constraints",
jsonData: `[
{"name": "import_test", "schema": []}
]`,
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "minimal collection import",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
},
{
name: "minimal collection import + failed beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return errors.New("test_error")
},
deleteMissing: false,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "minimal collection import + successful beforeRecordsSync",
jsonData: `[
{"name": "import_test", "schema": [{"name":"test", "type": "text"}]}
]`,
beforeRecordsSync: func(txDao *daos.Dao, mappedImported, mappedExisting map[string]*models.Collection) error {
return nil
},
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
},
{
name: "new + update + delete system collection",
jsonData: `[
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "new + update + delete non-system collection",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: true,
expectError: false,
expectCollectionsCount: 3,
},
{
name: "test with deleteMissing: false",
jsonData: `[
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":true
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"bool"
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"_2hlxbmp",
"name":"field_with_duplicate_id",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
},
{
"id":"abcd_import",
"name":"new_field",
"type":"text"
}
]
},
{
"name": "new_import",
"schema": [
{
"id":"abcd_import",
"name":"active",
"type":"bool"
}
]
}
]`,
deleteMissing: false,
expectError: false,
expectCollectionsCount: 6,
afterTestFunc: func(testApp *tests.TestApp, resultCollections []*models.Collection) {
expectedCollectionFields := map[string]int{
"profiles": 6,
"demo": 3,
"demo2": 14,
"demo3": 1,
"demo4": 6,
"new_import": 1,
}
for name, expectedCount := range expectedCollectionFields {
collection, err := testApp.Dao().FindCollectionByNameOrId(name)
if err != nil {
t.Fatal(err)
}
if totalFields := len(collection.Schema.Fields()); totalFields != expectedCount {
t.Errorf("Expected %d %q fields, got %d", expectedCount, collection.Name, totalFields)
}
}
},
},
}
for _, scenario := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
importedCollections := []*models.Collection{}
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), &importedCollections)
if loadErr != nil {
t.Fatalf("[%s] Failed to load data: %v", scenario.name, loadErr)
continue
}
err := testApp.Dao().ImportCollections(importedCollections, scenario.deleteMissing, scenario.beforeRecordsSync)
hasErr := err != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != scenario.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", scenario.name, scenario.expectCollectionsCount, len(collections))
}
if scenario.afterTestFunc != nil {
scenario.afterTestFunc(testApp, collections)
}
}
}
+6 -3
View File
@@ -58,11 +58,14 @@ func (dao *Dao) expandRecords(records []*models.Record, expandPath string, fetch
// extract the relation field (if exist)
mainCollection := records[0].Collection()
relField := mainCollection.Schema.GetFieldByName(parts[0])
if relField == nil {
return fmt.Errorf("Couldn't find field %q in collection %q.", parts[0], mainCollection.Name)
if relField == nil || relField.Type != schema.FieldTypeRelation {
return fmt.Errorf("Couldn't find relation field %q in collection %q.", parts[0], mainCollection.Name)
}
relField.InitOptions()
relFieldOptions, _ := relField.Options.(*schema.RelationOptions)
relFieldOptions, ok := relField.Options.(*schema.RelationOptions)
if !ok {
return fmt.Errorf("Cannot initialize the options of relation field %q.", parts[0])
}
relCollection, err := dao.FindCollectionByNameOrId(relFieldOptions.CollectionId)
if err != nil {
+12 -2
View File
@@ -63,7 +63,7 @@ func TestExpandRecords(t *testing.T) {
0,
2,
},
// invalid missing first level expand
// missing relation field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"invalid"},
@@ -73,7 +73,17 @@ func TestExpandRecords(t *testing.T) {
0,
1,
},
// invalid missing second level expand
// existing, but non-relation type field
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"title"},
func(c *models.Collection, ids []string) ([]*models.Record, error) {
return app.Dao().FindRecordsByIds(c, ids, nil)
},
0,
1,
},
// invalid/missing second level expand
{
[]string{"b8ba58f9-e2d7-42a0-b0e7-a11efd98236b", "df55c8ff-45ef-4c82-8aed-6e2183fe1125"},
[]string{"manyrels.invalid"},
-1
View File
@@ -298,7 +298,6 @@ func TestFindUserRelatedRecords(t *testing.T) {
}
if len(records) != len(scenario.expectedIds) {
fmt.Println(records[0])
t.Errorf("(%d) Expected %d records, got %d (%v)", i, len(scenario.expectedIds), len(records), records)
continue
}
+36 -5
View File
@@ -6,20 +6,51 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// AdminLogin defines an admin email/pass login form.
// AdminLogin specifies an admin email/pass login form.
type AdminLogin struct {
app core.App
config AdminLoginConfig
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
// NewAdminLogin creates new admin login form for the provided app.
// AdminLoginConfig is the [AdminLogin] factory initializer config.
//
// NB! App is a required struct member.
type AdminLoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminLogin creates a new [AdminLogin] form with initializer
// config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminLoginWithConfig] with explicitly set TxDao.
func NewAdminLogin(app core.App) *AdminLogin {
return &AdminLogin{app: app}
return NewAdminLoginWithConfig(AdminLoginConfig{
App: app,
})
}
// NewAdminLoginWithConfig creates a new [AdminLogin] form
// with the provided config or panics on invalid configuration.
func NewAdminLoginWithConfig(config AdminLoginConfig) *AdminLogin {
form := &AdminLogin{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -37,7 +68,7 @@ func (form *AdminLogin) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
if err != nil {
return nil, err
}
+10
View File
@@ -7,6 +7,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminLoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminLogin(nil)
}
func TestAdminLoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+39 -10
View File
@@ -3,24 +3,53 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminPasswordResetConfirm defines an admin password reset confirmation form.
// AdminPasswordResetConfirm specifies an admin password reset confirmation form.
type AdminPasswordResetConfirm struct {
app core.App
config AdminPasswordResetConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminPasswordResetConfirm creates new admin password reset confirmation form.
// AdminPasswordResetConfirmConfig is the [AdminPasswordResetConfirm] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminPasswordResetConfirm creates a new [AdminPasswordResetConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminPasswordResetConfirmWithConfig] with explicitly set TxDao.
func NewAdminPasswordResetConfirm(app core.App) *AdminPasswordResetConfirm {
return &AdminPasswordResetConfirm{
app: app,
return NewAdminPasswordResetConfirmWithConfig(AdminPasswordResetConfirmConfig{
App: app,
})
}
// NewAdminPasswordResetConfirmWithConfig creates a new [AdminPasswordResetConfirm]
// form with the provided config or panics on invalid configuration.
func NewAdminPasswordResetConfirmWithConfig(config AdminPasswordResetConfirmConfig) *AdminPasswordResetConfirm {
form := &AdminPasswordResetConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -38,9 +67,9 @@ func (form *AdminPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
admin, err := form.app.Dao().FindAdminByToken(
admin, err := form.config.TxDao.FindAdminByToken(
v,
form.app.Settings().AdminPasswordResetToken.Secret,
form.config.App.Settings().AdminPasswordResetToken.Secret,
)
if err != nil || admin == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -56,9 +85,9 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
admin, err := form.app.Dao().FindAdminByToken(
admin, err := form.config.TxDao.FindAdminByToken(
form.Token,
form.app.Settings().AdminPasswordResetToken.Secret,
form.config.App.Settings().AdminPasswordResetToken.Secret,
)
if err != nil {
return nil, err
@@ -68,7 +97,7 @@ func (form *AdminPasswordResetConfirm) Submit() (*models.Admin, error) {
return nil, err
}
if err := form.app.Dao().SaveAdmin(admin); err != nil {
if err := form.config.TxDao.SaveAdmin(admin); err != nil {
return nil, err
}
@@ -8,6 +8,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestAdminPasswordResetPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetConfirm(nil)
}
func TestAdminPasswordResetConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -11
View File
@@ -7,24 +7,53 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// AdminPasswordResetRequest defines an admin password reset request form.
// AdminPasswordResetRequest specifies an admin password reset request form.
type AdminPasswordResetRequest struct {
app core.App
resendThreshold float64
config AdminPasswordResetRequestConfig
Email string `form:"email" json:"email"`
}
// NewAdminPasswordResetRequest creates new admin password reset request form.
// AdminPasswordResetRequestConfig is the [AdminPasswordResetRequest] factory initializer config.
//
// NB! App is required struct member.
type AdminPasswordResetRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewAdminPasswordResetRequest creates a new [AdminPasswordResetRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewAdminPasswordResetRequestWithConfig] with explicitly set TxDao.
func NewAdminPasswordResetRequest(app core.App) *AdminPasswordResetRequest {
return &AdminPasswordResetRequest{
app: app,
resendThreshold: 120, // 2 min
return NewAdminPasswordResetRequestWithConfig(AdminPasswordResetRequestConfig{
App: app,
ResendThreshold: 120, // 2min
})
}
// NewAdminPasswordResetRequestWithConfig creates a new [AdminPasswordResetRequest]
// form with the provided config or panics on invalid configuration.
func NewAdminPasswordResetRequestWithConfig(config AdminPasswordResetRequestConfig) *AdminPasswordResetRequest {
form := &AdminPasswordResetRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,23 +77,23 @@ func (form *AdminPasswordResetRequest) Submit() error {
return err
}
admin, err := form.app.Dao().FindAdminByEmail(form.Email)
admin, err := form.config.TxDao.FindAdminByEmail(form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := admin.LastResetSentAt.Time()
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
return errors.New("You have already requested a password reset.")
}
if err := mails.SendAdminPasswordReset(form.app, admin); err != nil {
if err := mails.SendAdminPasswordReset(form.config.App, admin); err != nil {
return err
}
// update last sent timestamp
admin.LastResetSentAt = types.NowDateTime()
return form.app.Dao().SaveAdmin(admin)
return form.config.TxDao.SaveAdmin(admin)
}
@@ -7,6 +7,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminPasswordResetRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminPasswordResetRequest(nil)
}
func TestAdminPasswordResetRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+56 -12
View File
@@ -4,32 +4,62 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// AdminUpsert defines an admin upsert (create/update) form.
// AdminUpsert specifies a [models.Admin] upsert (create/update) form.
type AdminUpsert struct {
app core.App
admin *models.Admin
isCreate bool
config AdminUpsertConfig
admin *models.Admin
Id string `form:"id" json:"id"`
Avatar int `form:"avatar" json:"avatar"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewAdminUpsert creates new upsert form for the provided admin model
// (pass an empty admin model instance (`&models.Admin{}`) for create).
// AdminUpsertConfig is the [AdminUpsert] factory initializer config.
//
// NB! App is a required struct member.
type AdminUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewAdminUpsert creates a new [AdminUpsert] form with initializer
// config created from the provided [core.App] and [models.Admin] instances
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewAdminUpsertWithConfig] with explicitly set TxDao.
func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
return NewAdminUpsertWithConfig(AdminUpsertConfig{
App: app,
}, admin)
}
// NewAdminUpsertWithConfig creates a new [AdminUpsert] form
// with the provided config and [models.Admin] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Admin - `&models.Admin{}`).
func NewAdminUpsertWithConfig(config AdminUpsertConfig, admin *models.Admin) *AdminUpsert {
form := &AdminUpsert{
app: app,
admin: admin,
isCreate: !admin.HasId(),
config: config,
admin: admin,
}
if form.config.App == nil || form.admin == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Id = admin.Id
form.Avatar = admin.Avatar
form.Email = admin.Email
@@ -39,6 +69,14 @@ func NewAdminUpsert(app core.App, admin *models.Admin) *AdminUpsert {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *AdminUpsert) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.admin.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.admin.Id)),
),
validation.Field(
&form.Avatar,
validation.Min(0),
@@ -53,7 +91,7 @@ func (form *AdminUpsert) Validate() error {
),
validation.Field(
&form.Password,
validation.When(form.isCreate, validation.Required),
validation.When(form.admin.IsNew(), validation.Required),
validation.Length(10, 100),
),
validation.Field(
@@ -67,7 +105,7 @@ func (form *AdminUpsert) Validate() error {
func (form *AdminUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if form.app.Dao().IsAdminEmailUnique(v, form.admin.Id) {
if form.config.TxDao.IsAdminEmailUnique(v, form.admin.Id) {
return nil
}
@@ -83,6 +121,12 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// custom insertion id can be set only on create
if form.admin.IsNew() && form.Id != "" {
form.admin.MarkAsNew()
form.admin.SetId(form.Id)
}
form.admin.Avatar = form.Avatar
form.admin.Email = form.Email
@@ -91,6 +135,6 @@ func (form *AdminUpsert) Submit(interceptors ...InterceptorFunc) error {
}
return runInterceptors(func() error {
return form.app.Dao().SaveAdmin(form.admin)
return form.config.TxDao.SaveAdmin(form.admin)
}, interceptors...)
}
+126
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -11,6 +12,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestAdminUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(nil, nil)
}
func TestAdminUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewAdminUpsert(app, nil)
}
func TestNewAdminUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -347,3 +371,105 @@ func TestAdminUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestAdminUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingAdmin, err := app.Dao().FindAdminByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
jsonData string
collection *models.Admin
expectError bool
}{
{
"empty data",
"{}",
&models.Admin{},
false,
},
{
"empty id",
`{"id":""}`,
&models.Admin{},
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
&models.Admin{},
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
&models.Admin{},
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
&models.Admin{},
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingAdmin,
true,
},
{
"using the same existing item id",
`{"id":"` + existingAdmin.Id + `"}`,
existingAdmin,
false,
},
{
"skipping the id for existing item",
`{}`,
existingAdmin,
false,
},
}
for i, scenario := range scenarios {
form := forms.NewAdminUpsert(app, scenario.collection)
if form.Email == "" {
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
}
form.Password = "1234567890"
form.PasswordConfirm = form.Password
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindAdminById(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+5
View File
@@ -2,6 +2,11 @@
// validation and applying changes to existing DB models through the app Dao.
package forms
import "regexp"
// base ID value regex pattern
var idRegex = regexp.MustCompile(`^[^\@\#\$\&\|\.\,\'\"\\\/\s]+$`)
// InterceptorNextFunc is a interceptor handler function.
// Usually used in combination with InterceptorFunc.
type InterceptorNextFunc = func() error
+99 -26
View File
@@ -1,11 +1,13 @@
package forms
import (
"fmt"
"regexp"
"strings"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/resolvers"
@@ -14,12 +16,12 @@ import (
var collectionNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_]*$`)
// CollectionUpsert defines a collection upsert (create/update) form.
// CollectionUpsert specifies a [models.Collection] upsert (create/update) form.
type CollectionUpsert struct {
app core.App
config CollectionUpsertConfig
collection *models.Collection
isCreate bool
Id string `form:"id" json:"id"`
Name string `form:"name" json:"name"`
System bool `form:"system" json:"system"`
Schema schema.Schema `form:"schema" json:"schema"`
@@ -30,25 +32,54 @@ type CollectionUpsert struct {
DeleteRule *string `form:"deleteRule" json:"deleteRule"`
}
// NewCollectionUpsert creates new collection upsert form for the provided Collection model
// (pass an empty Collection model instance (`&models.Collection{}`) for create).
// CollectionUpsertConfig is the [CollectionUpsert] factory initializer config.
//
// NB! App is a required struct member.
type CollectionUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewCollectionUpsert creates a new [CollectionUpsert] form with initializer
// config created from the provided [core.App] and [models.Collection] instances
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewCollectionUpsertWithConfig] with explicitly set TxDao.
func NewCollectionUpsert(app core.App, collection *models.Collection) *CollectionUpsert {
return NewCollectionUpsertWithConfig(CollectionUpsertConfig{
App: app,
}, collection)
}
// NewCollectionUpsertWithConfig creates a new [CollectionUpsert] form
// with the provided config and [models.Collection] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Collection - `&models.Collection{}`).
func NewCollectionUpsertWithConfig(config CollectionUpsertConfig, collection *models.Collection) *CollectionUpsert {
form := &CollectionUpsert{
app: app,
config: config,
collection: collection,
isCreate: !collection.HasId(),
}
if form.config.App == nil || form.collection == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Name = collection.Name
form.System = collection.System
form.ListRule = collection.ListRule
form.ViewRule = collection.ViewRule
form.CreateRule = collection.CreateRule
form.UpdateRule = collection.UpdateRule
form.DeleteRule = collection.DeleteRule
form.Id = form.collection.Id
form.Name = form.collection.Name
form.System = form.collection.System
form.ListRule = form.collection.ListRule
form.ViewRule = form.collection.ViewRule
form.CreateRule = form.collection.CreateRule
form.UpdateRule = form.collection.UpdateRule
form.DeleteRule = form.collection.DeleteRule
clone, _ := collection.Schema.Clone()
clone, _ := form.collection.Schema.Clone()
if clone != nil {
form.Schema = *clone
} else {
@@ -61,6 +92,14 @@ func NewCollectionUpsert(app core.App, collection *models.Collection) *Collectio
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionUpsert) Validate() error {
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.collection.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.collection.Id)),
),
validation.Field(
&form.System,
validation.By(form.ensureNoSystemFlagChange),
@@ -78,6 +117,7 @@ func (form *CollectionUpsert) Validate() error {
&form.Schema,
validation.By(form.ensureNoSystemFieldsChange),
validation.By(form.ensureNoFieldsTypeChange),
validation.By(form.ensureExistingRelationCollectionId),
),
validation.Field(&form.ListRule, validation.By(form.checkRule)),
validation.Field(&form.ViewRule, validation.By(form.checkRule)),
@@ -90,11 +130,11 @@ func (form *CollectionUpsert) Validate() error {
func (form *CollectionUpsert) checkUniqueName(value any) error {
v, _ := value.(string)
if !form.app.Dao().IsCollectionNameUnique(v, form.collection.Id) {
if !form.config.TxDao.IsCollectionNameUnique(v, form.collection.Id) {
return validation.NewError("validation_collection_name_exists", "Collection name must be unique (case insensitive).")
}
if (form.isCreate || !strings.EqualFold(v, form.collection.Name)) && form.app.Dao().HasTable(v) {
if (form.collection.IsNew() || !strings.EqualFold(v, form.collection.Name)) && form.config.TxDao.HasTable(v) {
return validation.NewError("validation_collection_name_table_exists", "The collection name must be also unique table name.")
}
@@ -104,7 +144,7 @@ func (form *CollectionUpsert) checkUniqueName(value any) error {
func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
v, _ := value.(string)
if form.isCreate || !form.collection.System || v == form.collection.Name {
if form.collection.IsNew() || !form.collection.System || v == form.collection.Name {
return nil
}
@@ -114,7 +154,7 @@ func (form *CollectionUpsert) ensureNoSystemNameChange(value any) error {
func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
v, _ := value.(bool)
if form.isCreate || v == form.collection.System {
if form.collection.IsNew() || v == form.collection.System {
return nil
}
@@ -124,11 +164,38 @@ func (form *CollectionUpsert) ensureNoSystemFlagChange(value any) error {
func (form *CollectionUpsert) ensureNoFieldsTypeChange(value any) error {
v, _ := value.(schema.Schema)
for _, field := range v.Fields() {
for i, field := range v.Fields() {
oldField := form.collection.Schema.GetFieldById(field.Id)
if oldField != nil && oldField.Type != field.Type {
return validation.NewError("validation_field_type_change", "Field type cannot be changed.")
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_type_change",
"Field type cannot be changed.",
)}
}
}
return nil
}
func (form *CollectionUpsert) ensureExistingRelationCollectionId(value any) error {
v, _ := value.(schema.Schema)
for i, field := range v.Fields() {
if field.Type != schema.FieldTypeRelation {
continue
}
options, _ := field.Options.(*schema.RelationOptions)
if options == nil {
continue
}
if _, err := form.config.TxDao.FindCollectionByNameOrId(options.CollectionId); err != nil {
return validation.Errors{fmt.Sprint(i): validation.NewError(
"validation_field_invalid_relation",
"The relation collection doesn't exist.",
)}
}
}
@@ -161,7 +228,7 @@ func (form *CollectionUpsert) checkRule(value any) error {
}
dummy := &models.Collection{Schema: form.Schema}
r := resolvers.NewRecordFieldResolver(form.app.Dao(), dummy, nil)
r := resolvers.NewRecordFieldResolver(form.config.TxDao, dummy, nil)
_, err := search.FilterData(*v).BuildExpr(r)
if err != nil {
@@ -182,13 +249,19 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// system flag can be set only for create
if form.isCreate {
if form.collection.IsNew() {
// system flag can be set only on create
form.collection.System = form.System
// custom insertion id can be set only on create
if form.Id != "" {
form.collection.MarkAsNew()
form.collection.SetId(form.Id)
}
}
// system collections cannot be renamed
if form.isCreate || !form.collection.System {
if form.collection.IsNew() || !form.collection.System {
form.collection.Name = form.Name
}
@@ -200,6 +273,6 @@ func (form *CollectionUpsert) Submit(interceptors ...InterceptorFunc) error {
form.collection.DeleteRule = form.DeleteRule
return runInterceptors(func() error {
return form.app.Dao().SaveCollection(form.collection)
return form.config.TxDao.SaveCollection(form.collection)
}, interceptors...)
}
+128
View File
@@ -10,9 +10,33 @@ import (
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/models/schema"
"github.com/pocketbase/pocketbase/tests"
"github.com/pocketbase/pocketbase/tools/security"
"github.com/spf13/cast"
)
func TestCollectionUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(nil, nil)
}
func TestCollectionUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionUpsert(app, nil)
}
func TestNewCollectionUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -518,3 +542,107 @@ func TestCollectionUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestCollectionUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingCollection, err := app.Dao().FindCollectionByNameOrId("demo3")
if err != nil {
t.Fatal(err)
}
newCollection := func() *models.Collection {
return &models.Collection{
Name: "c_" + security.RandomString(4),
Schema: existingCollection.Schema,
}
}
scenarios := []struct {
name string
jsonData string
collection *models.Collection
expectError bool
}{
{
"empty data",
"{}",
newCollection(),
false,
},
{
"empty id",
`{"id":""}`,
newCollection(),
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
newCollection(),
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
newCollection(),
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
newCollection(),
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
newCollection(),
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingCollection,
true,
},
{
"using the same existing item id",
`{"id":"` + existingCollection.Id + `"}`,
existingCollection,
false,
},
{
"skipping the id for existing item",
`{}`,
existingCollection,
false,
},
}
for _, scenario := range scenarios {
form := forms.NewCollectionUpsert(app, scenario.collection)
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindCollectionByNameOrId(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
package forms
import (
"encoding/json"
"fmt"
"log"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// CollectionsImport specifies a form model to bulk import
// (create, replace and delete) collections from a user provided list.
type CollectionsImport struct {
config CollectionsImportConfig
Collections []*models.Collection `form:"collections" json:"collections"`
DeleteMissing bool `form:"deleteMissing" json:"deleteMissing"`
}
// CollectionsImportConfig is the [CollectionsImport] factory initializer config.
//
// NB! App is a required struct member.
type CollectionsImportConfig struct {
App core.App
TxDao *daos.Dao
}
// NewCollectionsImport creates a new [CollectionsImport] form with
// initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewCollectionsImportWithConfig] with explicitly set TxDao.
func NewCollectionsImport(app core.App) *CollectionsImport {
return NewCollectionsImportWithConfig(CollectionsImportConfig{
App: app,
})
}
// NewCollectionsImportWithConfig creates a new [CollectionsImport]
// form with the provided config or panics on invalid configuration.
func NewCollectionsImportWithConfig(config CollectionsImportConfig) *CollectionsImport {
form := &CollectionsImport{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *CollectionsImport) Validate() error {
return validation.ValidateStruct(form,
validation.Field(&form.Collections, validation.Required),
)
}
// Submit applies the import, aka.:
// - imports the form collections (create or replace)
// - sync the collection changes with their related records table
// - ensures the integrity of the imported structure (aka. run validations for each collection)
// - if [form.DeleteMissing] is set, deletes all local collections that are not found in the imports list
//
// All operations are wrapped in a single transaction that are
// rollbacked on the first encountered error.
//
// You can optionally provide a list of InterceptorFunc to further
// modify the form behavior before persisting it.
func (form *CollectionsImport) Submit(interceptors ...InterceptorFunc) error {
if err := form.Validate(); err != nil {
return err
}
return runInterceptors(func() error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
importErr := txDao.ImportCollections(
form.Collections,
form.DeleteMissing,
form.beforeRecordsSync,
)
if importErr == nil {
return nil
}
// validation failure
if err, ok := importErr.(validation.Errors); ok {
return err
}
// generic/db failure
if form.config.App.IsDebug() {
log.Println("Internal import failure:", importErr)
}
return validation.Errors{"collections": validation.NewError(
"collections_import_failure",
"Failed to import the collections configuration.",
)}
})
}, interceptors...)
}
func (form *CollectionsImport) beforeRecordsSync(txDao *daos.Dao, mappedNew, mappedOld map[string]*models.Collection) error {
// refresh the actual persisted collections list
refreshedCollections := []*models.Collection{}
if err := txDao.CollectionQuery().OrderBy("created ASC").All(&refreshedCollections); err != nil {
return err
}
// trigger the validator for each existing collection to
// ensure that the app is not left in a broken state
for _, collection := range refreshedCollections {
upsertModel := mappedOld[collection.GetId()]
if upsertModel == nil {
upsertModel = collection
}
upsertForm := NewCollectionUpsertWithConfig(CollectionUpsertConfig{
App: form.config.App,
TxDao: txDao,
}, upsertModel)
// load form fields with the refreshed collection state
upsertForm.Id = collection.Id
upsertForm.Name = collection.Name
upsertForm.System = collection.System
upsertForm.ListRule = collection.ListRule
upsertForm.ViewRule = collection.ViewRule
upsertForm.CreateRule = collection.CreateRule
upsertForm.UpdateRule = collection.UpdateRule
upsertForm.DeleteRule = collection.DeleteRule
upsertForm.Schema = collection.Schema
if err := upsertForm.Validate(); err != nil {
// serialize the validation error(s)
serializedErr, _ := json.MarshalIndent(err, "", " ")
return validation.Errors{"collections": validation.NewError(
"collections_import_validate_failure",
fmt.Sprintf("Data validations failed for collection %q (%s):\n%s", collection.Name, collection.Id, serializedErr),
)}
}
}
return nil
}
+419
View File
@@ -0,0 +1,419 @@
package forms_test
import (
"encoding/json"
"errors"
"testing"
"github.com/pocketbase/pocketbase/forms"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tests"
)
func TestCollectionsImportPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewCollectionsImport(nil)
}
func TestCollectionsImportValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
form := forms.NewCollectionsImport(app)
scenarios := []struct {
collections []*models.Collection
expectError bool
}{
{nil, true},
{[]*models.Collection{}, true},
{[]*models.Collection{{}}, false},
}
for i, s := range scenarios {
form.Collections = s.collections
err := form.Validate()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("(%d) Expected hasErr to be %v, got %v (%v)", i, s.expectError, hasErr, err)
}
}
}
func TestCollectionsImportSubmit(t *testing.T) {
scenarios := []struct {
name string
jsonData string
expectError bool
expectCollectionsCount int
expectEvents map[string]int
}{
{
name: "empty collections",
jsonData: `{
"deleteMissing": true,
"collections": []
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: nil,
},
{
name: "one of the collections has invalid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import 2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
},
},
{
name: "all imported collections has valid data",
jsonData: `{
"collections": [
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
},
{
name: "new collection with existing name",
jsonData: `{
"collections": [
{
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
expectEvents: map[string]int{
"OnModelBeforeCreate": 1,
},
},
{
name: "delete system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: true,
expectCollectionsCount: 5,
},
{
name: "modified + new collection",
jsonData: `{
"collections": [
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"name": "import1",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
},
{
"name": "import2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 7,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 1,
"OnModelAfterUpdate": 1,
"OnModelBeforeCreate": 2,
"OnModelAfterCreate": 2,
},
},
{
name: "delete non-system + modified + new collection",
jsonData: `{
"deleteMissing": true,
"collections": [
{
"id":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"name":"profiles",
"system":true,
"listRule":"userId = @request.user.id",
"viewRule":"created > 'test_change'",
"createRule":"userId = @request.user.id",
"updateRule":"userId = @request.user.id",
"deleteRule":"userId = @request.user.id",
"schema":[
{
"id":"koih1lqx",
"name":"userId",
"type":"user",
"system":true,
"required":true,
"unique":true,
"options":{
"maxSelect":1,
"cascadeDelete":true
}
},
{
"id":"69ycbg3q",
"name":"rel",
"type":"relation",
"system":false,
"required":false,
"unique":false,
"options":{
"maxSelect":2,
"collectionId":"abe78266-fd4d-4aea-962d-8c0138ac522b",
"cascadeDelete":false
}
}
]
},
{
"id":"3f2888f8-075d-49fe-9d09-ea7e951000dc",
"name":"demo",
"schema":[
{
"id":"_2hlxbmp",
"name":"title",
"type":"text",
"system":false,
"required":true,
"unique":false,
"options":{
"min":3,
"max":null,
"pattern":""
}
}
]
},
{
"id": "test_deleted_collection_name_reuse",
"name": "demo2",
"schema": [
{
"id":"fz6iql2m",
"name":"active",
"type":"bool"
}
]
}
]
}`,
expectError: false,
expectCollectionsCount: 3,
expectEvents: map[string]int{
"OnModelBeforeUpdate": 2,
"OnModelAfterUpdate": 2,
"OnModelBeforeCreate": 1,
"OnModelAfterCreate": 1,
"OnModelBeforeDelete": 3,
"OnModelAfterDelete": 3,
},
},
}
for _, s := range scenarios {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
form := forms.NewCollectionsImport(testApp)
// load data
loadErr := json.Unmarshal([]byte(s.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", s.name, loadErr)
continue
}
err := form.Submit()
hasErr := err != nil
if hasErr != s.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", s.name, s.expectError, hasErr, err)
}
// check collections count
collections := []*models.Collection{}
if err := testApp.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
if len(collections) != s.expectCollectionsCount {
t.Errorf("[%s] Expected %d collections, got %d", s.name, s.expectCollectionsCount, len(collections))
}
// check events
if len(testApp.EventCalls) > len(s.expectEvents) {
t.Errorf("[%s] Expected events %v, got %v", s.name, s.expectEvents, testApp.EventCalls)
}
for event, expectedCalls := range s.expectEvents {
actualCalls := testApp.EventCalls[event]
if actualCalls != expectedCalls {
t.Errorf("[%s] Expected event %s to be called %d, got %d", s.name, event, expectedCalls, actualCalls)
}
}
}
}
func TestCollectionsImportSubmitInterceptors(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collections := []*models.Collection{}
if err := app.Dao().CollectionQuery().All(&collections); err != nil {
t.Fatal(err)
}
form := forms.NewCollectionsImport(app)
form.Collections = collections
testErr := errors.New("test_error")
interceptor1Called := false
interceptor1 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor1Called = true
return next()
}
}
interceptor2Called := false
interceptor2 := func(next forms.InterceptorNextFunc) forms.InterceptorNextFunc {
return func() error {
interceptor2Called = true
return testErr
}
}
submitErr := form.Submit(interceptor1, interceptor2)
if submitErr != testErr {
t.Fatalf("Expected submitError %v, got %v", testErr, submitErr)
}
if !interceptor1Called {
t.Fatalf("Expected interceptor1 to be called")
}
if !interceptor2Called {
t.Fatalf("Expected interceptor2 to be called")
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
)
// RealtimeSubscribe defines a RealtimeSubscribe request form.
// RealtimeSubscribe specifies a RealtimeSubscribe request form.
type RealtimeSubscribe struct {
ClientId string `form:"clientId" json:"clientId"`
Subscriptions []string `form:"subscriptions" json:"subscriptions"`
+93 -13
View File
@@ -4,10 +4,12 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
@@ -19,29 +21,59 @@ import (
"github.com/spf13/cast"
)
// RecordUpsert defines a Record upsert form.
// RecordUpsert specifies a [models.Record] upsert (create/update) form.
type RecordUpsert struct {
app core.App
config RecordUpsertConfig
record *models.Record
isCreate bool
filesToDelete []string // names list
filesToUpload []*rest.UploadedFile
Id string `form:"id" json:"id"`
Data map[string]any `json:"data"`
}
// NewRecordUpsert creates a new Record upsert form.
// (pass a new Record model instance (`models.NewRecord(...)`) for create).
// RecordUpsertConfig is the [RecordUpsert] factory initializer config.
//
// NB! App is required struct member.
type RecordUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewRecordUpsert creates a new [RecordUpsert] form with initializer
// config created from the provided [core.App] and [models.Record] instances
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
//
// If you want to submit the form as part of another transaction, use
// [NewRecordUpsertWithConfig] with explicitly set TxDao.
func NewRecordUpsert(app core.App, record *models.Record) *RecordUpsert {
return NewRecordUpsertWithConfig(RecordUpsertConfig{
App: app,
}, record)
}
// NewRecordUpsertWithConfig creates a new [RecordUpsert] form
// with the provided config and [models.Record] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty Record - `models.NewRecord(collection)`).
func NewRecordUpsertWithConfig(config RecordUpsertConfig, record *models.Record) *RecordUpsert {
form := &RecordUpsert{
app: app,
config: config,
record: record,
isCreate: !record.HasId(),
filesToDelete: []string{},
filesToUpload: []*rest.UploadedFile{},
}
if form.config.App == nil || form.record == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
form.Id = record.Id
form.Data = map[string]any{}
for _, field := range record.Collection().Schema.Fields() {
form.Data[field.Name] = record.GetDataValue(field.Name)
@@ -135,6 +167,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
return err
}
if id, ok := requestData["id"]; ok {
form.Id = cast.ToString(id)
}
// extend base data with the extracted one
extendedData := form.record.Data()
rawData, err := json.Marshal(requestData)
@@ -201,6 +237,10 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// check if there are any new uploaded form files
files, err := rest.FindUploadedFiles(r, key)
if err != nil {
if form.config.App.IsDebug() {
log.Printf("%q uploaded file error: %v\n", key, err)
}
continue // skip invalid or missing file(s)
}
@@ -229,8 +269,24 @@ func (form *RecordUpsert) LoadData(r *http.Request) error {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *RecordUpsert) Validate() error {
// base form fields validator
baseFieldsErrors := validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.record.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.record.Id)),
),
)
if baseFieldsErrors != nil {
return baseFieldsErrors
}
// record data validator
dataValidator := validators.NewRecordDataValidator(
form.app.Dao(),
form.config.TxDao,
form.record,
form.filesToUpload,
)
@@ -247,17 +303,26 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
isNew := form.record.IsNew()
// custom insertion id can be set only on create
if isNew && form.Id != "" {
form.record.MarkAsNew()
form.record.SetId(form.Id)
}
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
return err
}
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
tx, ok := txDao.DB().(*dbx.Tx)
if !ok {
return errors.New("failed to get transaction db")
}
defer tx.Rollback()
txDao.BeforeCreateFunc = nil
txDao.AfterCreateFunc = nil
txDao.BeforeUpdateFunc = nil
@@ -267,7 +332,16 @@ func (form *RecordUpsert) DrySubmit(callback func(txDao *daos.Dao) error) error
return err
}
return callback(txDao)
// restore record isNew state
if isNew {
form.record.MarkAsNew()
}
if callback != nil {
return callback(txDao)
}
return nil
})
}
@@ -280,13 +354,19 @@ func (form *RecordUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
// custom insertion id can be set only on create
if form.record.IsNew() && form.Id != "" {
form.record.MarkAsNew()
form.record.SetId(form.Id)
}
// bulk load form data
if err := form.record.Load(form.Data); err != nil {
return err
}
return runInterceptors(func() error {
return form.app.Dao().RunInTransaction(func(txDao *daos.Dao) error {
return form.config.TxDao.RunInTransaction(func(txDao *daos.Dao) error {
// persist record model
if err := txDao.SaveRecord(form.record); err != nil {
return err
@@ -317,7 +397,7 @@ func (form *RecordUpsert) processFilesToUpload() error {
return errors.New("The record is not persisted yet.")
}
fs, err := form.app.NewFilesystem()
fs, err := form.config.App.NewFilesystem()
if err != nil {
return err
}
@@ -353,7 +433,7 @@ func (form *RecordUpsert) processFilesToDelete() error {
return errors.New("The record is not persisted yet.")
}
fs, err := form.app.NewFilesystem()
fs, err := form.config.App.NewFilesystem()
if err != nil {
return err
}
+142 -1
View File
@@ -20,6 +20,29 @@ import (
"github.com/pocketbase/pocketbase/tools/list"
)
func TestRecordUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewRecordUpsert(nil, nil)
}
func TestRecordUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewRecordUpsert(app, nil)
}
func TestNewRecordUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -32,7 +55,7 @@ func TestNewRecordUpsert(t *testing.T) {
val := form.Data["title"]
if val != "test_value" {
t.Errorf("Expected record data to be load, got %v", form.Data)
t.Errorf("Expected record data to be loaded, got %v", form.Data)
}
}
@@ -68,6 +91,7 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
}
testData := map[string]any{
"id": "test_id",
"title": "test123",
"unknown": "test456",
// file fields unset/delete
@@ -86,6 +110,10 @@ func TestRecordUpsertLoadDataJson(t *testing.T) {
t.Fatal(loadErr)
}
if form.Id != "test_id" {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["title"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
@@ -134,6 +162,7 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": "test_id",
"title": "test123",
"unknown": "test456",
// file fields unset/delete
@@ -154,6 +183,10 @@ func TestRecordUpsertLoadDataMultipart(t *testing.T) {
t.Fatal(loadErr)
}
if form.Id != "test_id" {
t.Fatalf("Expect id field to be %q, got %q", "test_id", form.Id)
}
if v, ok := form.Data["title"]; !ok || v != "test123" {
t.Fatalf("Expect title field to be %q, got %q", "test123", v)
}
@@ -202,6 +235,7 @@ func TestRecordUpsertValidateFailure(t *testing.T) {
// try with invalid test data to check whether the RecordDataValidator is triggered
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": "",
"unknown": "test456", // should be ignored
"title": "a",
"onerel": "00000000-84ab-4057-a592-4604a731f78f",
@@ -247,6 +281,7 @@ func TestRecordUpsertValidateSuccess(t *testing.T) {
}
formData, mp, err := tests.MockMultipartData(map[string]string{
"id": record.Id,
"unknown": "test456", // should be ignored
"title": "abc",
"onerel": "054f9f24-0a0a-4e09-87b1-bc7ff2b336a2",
@@ -576,3 +611,109 @@ func hasRecordFile(app core.App, record *models.Record, filename string) bool {
return exists
}
func TestRecordUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
collection, _ := app.Dao().FindCollectionByNameOrId("demo3")
existingRecord, err := app.Dao().FindFirstRecordByData(collection, "id", "2c542824-9de1-42fe-8924-e57c86267760")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
data map[string]string
record *models.Record
expectError bool
}{
{
"empty data",
map[string]string{},
models.NewRecord(collection),
false,
},
{
"empty id",
map[string]string{"id": ""},
models.NewRecord(collection),
false,
},
{
"id < 15 chars",
map[string]string{"id": "a23"},
models.NewRecord(collection),
true,
},
{
"id > 15 chars",
map[string]string{"id": "a234567890123456"},
models.NewRecord(collection),
true,
},
{
"id = 15 chars (invalid chars)",
map[string]string{"id": "a@3456789012345"},
models.NewRecord(collection),
true,
},
{
"id = 15 chars (valid chars)",
map[string]string{"id": "a23456789012345"},
models.NewRecord(collection),
false,
},
{
"changing the id of an existing record",
map[string]string{"id": "b23456789012345"},
existingRecord,
true,
},
{
"using the same existing record id",
map[string]string{"id": existingRecord.Id},
existingRecord,
false,
},
{
"skipping the id for existing record",
map[string]string{},
existingRecord,
false,
},
}
for _, scenario := range scenarios {
formData, mp, err := tests.MockMultipartData(scenario.data)
if err != nil {
t.Fatal(err)
}
form := forms.NewRecordUpsert(app, scenario.record)
req := httptest.NewRequest(http.MethodGet, "/", formData)
req.Header.Set(echo.HeaderContentType, mp.FormDataContentType())
form.LoadData(req)
dryErr := form.DrySubmit(nil)
hasDryErr := dryErr != nil
submitErr := form.Submit()
hasSubmitErr := submitErr != nil
if hasDryErr != hasSubmitErr {
t.Errorf("[%s] Expected hasDryErr and hasSubmitErr to have the same value, got %v vs %v", scenario.name, hasDryErr, hasSubmitErr)
}
if hasSubmitErr != scenario.expectError {
t.Errorf("[%s] Expected hasSubmitErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasSubmitErr, submitErr)
}
if id, ok := scenario.data["id"]; ok && id != "" && !hasSubmitErr {
_, err := app.Dao().FindRecordById(collection, id, nil)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, id, err)
}
}
}
}
+43 -9
View File
@@ -5,22 +5,56 @@ import (
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// SettingsUpsert defines app settings upsert form.
// SettingsUpsert specifies a [core.Settings] upsert (create/update) form.
type SettingsUpsert struct {
*core.Settings
app core.App
config SettingsUpsertConfig
}
// NewSettingsUpsert creates new settings upsert form from the provided app.
// SettingsUpsertConfig is the [SettingsUpsert] factory initializer config.
//
// NB! App is required struct member.
type SettingsUpsertConfig struct {
App core.App
TxDao *daos.Dao
TxLogsDao *daos.Dao
}
// NewSettingsUpsert creates a new [SettingsUpsert] form with initializer
// config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewSettingsUpsertWithConfig] with explicitly set TxDao.
func NewSettingsUpsert(app core.App) *SettingsUpsert {
form := &SettingsUpsert{app: app}
return NewSettingsUpsertWithConfig(SettingsUpsertConfig{
App: app,
})
}
// NewSettingsUpsertWithConfig creates a new [SettingsUpsert] form
// with the provided config or panics on invalid configuration.
func NewSettingsUpsertWithConfig(config SettingsUpsertConfig) *SettingsUpsert {
form := &SettingsUpsert{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
if form.config.TxLogsDao == nil {
form.config.TxLogsDao = form.config.App.LogsDao()
}
// load the application settings into the form
form.Settings, _ = app.Settings().Clone()
form.Settings, _ = config.App.Settings().Clone()
return form
}
@@ -41,10 +75,10 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
return err
}
encryptionKey := os.Getenv(form.app.EncryptionEnv())
encryptionKey := os.Getenv(form.config.App.EncryptionEnv())
return runInterceptors(func() error {
saveErr := form.app.Dao().SaveParam(
saveErr := form.config.TxDao.SaveParam(
models.ParamAppSettings,
form.Settings,
encryptionKey,
@@ -54,11 +88,11 @@ func (form *SettingsUpsert) Submit(interceptors ...InterceptorFunc) error {
}
// explicitly trigger old logs deletion
form.app.LogsDao().DeleteOldRequests(
form.config.TxLogsDao.DeleteOldRequests(
time.Now().AddDate(0, 0, -1*form.Settings.Logs.MaxDays),
)
// merge the application settings with the form ones
return form.app.Settings().Merge(form.Settings)
return form.config.App.Settings().Merge(form.Settings)
}, interceptors...)
}
+10
View File
@@ -12,6 +12,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestSettingsUpsertPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewSettingsUpsert(nil)
}
func TestNewSettingsUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -10
View File
@@ -3,23 +3,53 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/security"
)
// UserEmailChangeConfirm defines a user email change confirmation form.
// UserEmailChangeConfirm specifies a user email change confirmation form.
type UserEmailChangeConfirm struct {
app core.App
config UserEmailChangeConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
}
// NewUserEmailChangeConfirm creates new user email change confirmation form.
// UserEmailChangeConfirmConfig is the [UserEmailChangeConfirm] factory initializer config.
//
// NB! App is required struct member.
type UserEmailChangeConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailChangeConfirm creates a new [UserEmailChangeConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// This factory method is used primarily for convenience (and backward compatibility).
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserEmailChangeConfirm(app core.App) *UserEmailChangeConfirm {
return &UserEmailChangeConfirm{
app: app,
return NewUserEmailChangeConfirmWithConfig(UserEmailChangeConfirmConfig{
App: app,
})
}
// NewUserEmailChangeConfirmWithConfig creates a new [UserEmailChangeConfirm]
// form with the provided config or panics on invalid configuration.
func NewUserEmailChangeConfirmWithConfig(config UserEmailChangeConfirmConfig) *UserEmailChangeConfirm {
form := &UserEmailChangeConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -73,14 +103,14 @@ func (form *UserEmailChangeConfirm) parseToken(token string) (*models.User, stri
}
// ensure that there aren't other users with the new email
if !form.app.Dao().IsUserEmailUnique(newEmail, "") {
if !form.config.TxDao.IsUserEmailUnique(newEmail, "") {
return nil, "", validation.NewError("validation_existing_token_email", "The new email address is already registered: "+newEmail)
}
// verify that the token is not expired and its signiture is valid
user, err := form.app.Dao().FindUserByToken(
// verify that the token is not expired and its signature is valid
user, err := form.config.TxDao.FindUserByToken(
token,
form.app.Settings().UserEmailChangeToken.Secret,
form.config.App.Settings().UserEmailChangeToken.Secret,
)
if err != nil || user == nil {
return nil, "", validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -105,7 +135,7 @@ func (form *UserEmailChangeConfirm) Submit() (*models.User, error) {
user.Verified = true
user.RefreshTokenKey() // invalidate old tokens
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserEmailChangeConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeConfirm(nil)
}
func TestUserEmailChangeConfirmValidateAndSubmit(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+39 -8
View File
@@ -4,24 +4,55 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/models"
)
// UserEmailChangeRequest defines a user email change request form.
type UserEmailChangeRequest struct {
app core.App
user *models.User
config UserEmailChangeRequestConfig
user *models.User
NewEmail string `form:"newEmail" json:"newEmail"`
}
// NewUserEmailChangeRequest creates a new user email change request form.
// UserEmailChangeRequestConfig is the [UserEmailChangeRequest] factory initializer config.
//
// NB! App is required struct member.
type UserEmailChangeRequestConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailChangeRequest creates a new [UserEmailChangeRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserEmailChangeRequest(app core.App, user *models.User) *UserEmailChangeRequest {
return &UserEmailChangeRequest{
app: app,
user: user,
return NewUserEmailChangeRequestWithConfig(UserEmailChangeRequestConfig{
App: app,
}, user)
}
// NewUserEmailChangeRequestWithConfig creates a new [UserEmailChangeRequest]
// form with the provided config or panics on invalid configuration.
func NewUserEmailChangeRequestWithConfig(config UserEmailChangeRequestConfig, user *models.User) *UserEmailChangeRequest {
form := &UserEmailChangeRequest{
config: config,
user: user,
}
if form.config.App == nil || form.user == nil {
panic("Invalid initializer config or nil user model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -40,7 +71,7 @@ func (form *UserEmailChangeRequest) Validate() error {
func (form *UserEmailChangeRequest) checkUniqueEmail(value any) error {
v, _ := value.(string)
if !form.app.Dao().IsUserEmailUnique(v, "") {
if !form.config.TxDao.IsUserEmailUnique(v, "") {
return validation.NewError("validation_user_email_exists", "User email already exists.")
}
@@ -53,5 +84,5 @@ func (form *UserEmailChangeRequest) Submit() error {
return err
}
return mails.SendUserChangeEmail(form.app, form.user, form.NewEmail)
return mails.SendUserChangeEmail(form.config.App, form.user, form.NewEmail)
}
+23
View File
@@ -9,6 +9,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserEmailChangeRequestPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeRequest(nil, nil)
}
func TestUserEmailChangeRequestPanic2(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailChangeRequest(testApp, nil)
}
func TestUserEmailChangeRequestValidateAndSubmit(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+34 -6
View File
@@ -4,21 +4,49 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// UserEmailLogin defines a user email/pass login form.
// UserEmailLogin specifies a user email/pass login form.
type UserEmailLogin struct {
app core.App
config UserEmailLoginConfig
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
}
// NewUserEmailLogin creates a new user email/pass login form.
// UserEmailLoginConfig is the [UserEmailLogin] factory initializer config.
//
// NB! App is required struct member.
type UserEmailLoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserEmailLogin creates a new [UserEmailLogin] form with
// initializer config created from the provided [core.App] instance.
//
// This factory method is used primarily for convenience (and backward compatibility).
// If you want to submit the form as part of another transaction, use
// [NewUserEmailLoginWithConfig] with explicitly set TxDao.
func NewUserEmailLogin(app core.App) *UserEmailLogin {
form := &UserEmailLogin{
app: app,
return NewUserEmailLoginWithConfig(UserEmailLoginConfig{
App: app,
})
}
// NewUserEmailLoginWithConfig creates a new [UserEmailLogin]
// form with the provided config or panics on invalid configuration.
func NewUserEmailLoginWithConfig(config UserEmailLoginConfig) *UserEmailLogin {
form := &UserEmailLogin{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
@@ -39,7 +67,7 @@ func (form *UserEmailLogin) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return nil, err
}
+10
View File
@@ -9,6 +9,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserEmailLoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserEmailLogin(nil)
}
func TestUserEmailLoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+45 -11
View File
@@ -7,15 +7,16 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/auth"
"github.com/pocketbase/pocketbase/tools/security"
"golang.org/x/oauth2"
)
// UserOauth2Login defines a user Oauth2 login form.
// UserOauth2Login specifies a user Oauth2 login form.
type UserOauth2Login struct {
app core.App
config UserOauth2LoginConfig
// The name of the OAuth2 client provider (eg. "google")
Provider string `form:"provider" json:"provider"`
@@ -30,9 +31,39 @@ type UserOauth2Login struct {
RedirectUrl string `form:"redirectUrl" json:"redirectUrl"`
}
// NewUserOauth2Login creates a new user Oauth2 login form.
// UserOauth2LoginConfig is the [UserOauth2Login] factory initializer config.
//
// NB! App is required struct member.
type UserOauth2LoginConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserOauth2Login creates a new [UserOauth2Login] form with
// initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserOauth2LoginWithConfig] with explicitly set TxDao.
func NewUserOauth2Login(app core.App) *UserOauth2Login {
return &UserOauth2Login{app: app}
return NewUserOauth2LoginWithConfig(UserOauth2LoginConfig{
App: app,
})
}
// NewUserOauth2LoginWithConfig creates a new [UserOauth2Login]
// form with the provided config or panics on invalid configuration.
func NewUserOauth2LoginWithConfig(config UserOauth2LoginConfig) *UserOauth2Login {
form := &UserOauth2Login{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,7 +79,7 @@ func (form *UserOauth2Login) Validate() error {
func (form *UserOauth2Login) checkProviderName(value any) error {
name, _ := value.(string)
config, ok := form.app.Settings().NamedAuthProviderConfigs()[name]
config, ok := form.config.App.Settings().NamedAuthProviderConfigs()[name]
if !ok || !config.Enabled {
return validation.NewError("validation_invalid_provider", fmt.Sprintf("%q is missing or is not enabled.", name))
}
@@ -68,7 +99,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, nil, err
}
config := form.app.Settings().NamedAuthProviderConfigs()[form.Provider]
config := form.config.App.Settings().NamedAuthProviderConfigs()[form.Provider]
config.SetupProvider(provider)
provider.SetRedirectUrl(form.RedirectUrl)
@@ -89,12 +120,12 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
}
// login/register the auth user
user, _ := form.app.Dao().FindUserByEmail(authData.Email)
user, _ := form.config.TxDao.FindUserByEmail(authData.Email)
if user != nil {
// update the existing user's verified state
if !user.Verified {
user.Verified = true
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, authData, err
}
}
@@ -108,7 +139,10 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
// create new user
user = &models.User{Verified: true}
upsertForm := NewUserUpsert(form.app, user)
upsertForm := NewUserUpsertWithConfig(UserUpsertConfig{
App: form.config.App,
TxDao: form.config.TxDao,
}, user)
upsertForm.Email = authData.Email
upsertForm.Password = security.RandomString(30)
upsertForm.PasswordConfirm = upsertForm.Password
@@ -118,7 +152,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
AuthData: authData,
}
if err := form.app.OnUserBeforeOauth2Register().Trigger(event); err != nil {
if err := form.config.App.OnUserBeforeOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
@@ -126,7 +160,7 @@ func (form *UserOauth2Login) Submit() (*models.User, *auth.AuthUser, error) {
return nil, authData, err
}
if err := form.app.OnUserAfterOauth2Register().Trigger(event); err != nil {
if err := form.config.App.OnUserAfterOauth2Register().Trigger(event); err != nil {
return nil, authData, err
}
+10
View File
@@ -9,6 +9,16 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserOauth2LoginPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserOauth2Login(nil)
}
func TestUserOauth2LoginValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+41 -11
View File
@@ -3,29 +3,59 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
)
// UserPasswordResetConfirm defines a user password reset confirmation form.
// UserPasswordResetConfirm specifies a user password reset confirmation form.
type UserPasswordResetConfirm struct {
app core.App
config UserPasswordResetConfirmConfig
Token string `form:"token" json:"token"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewUserPasswordResetConfirm creates new user password reset confirmation form.
// UserPasswordResetConfirmConfig is the [UserPasswordResetConfirm]
// factory initializer config.
//
// NB! App is required struct member.
type UserPasswordResetConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserPasswordResetConfirm creates a new [UserPasswordResetConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserPasswordResetConfirmWithConfig] with explicitly set TxDao.
func NewUserPasswordResetConfirm(app core.App) *UserPasswordResetConfirm {
return &UserPasswordResetConfirm{
app: app,
return NewUserPasswordResetConfirmWithConfig(UserPasswordResetConfirmConfig{
App: app,
})
}
// NewUserPasswordResetConfirmWithConfig creates a new [UserPasswordResetConfirm]
// form with the provided config or panics on invalid configuration.
func NewUserPasswordResetConfirmWithConfig(config UserPasswordResetConfirmConfig) *UserPasswordResetConfirm {
form := &UserPasswordResetConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *UserPasswordResetConfirm) Validate() error {
minPasswordLength := form.app.Settings().EmailAuth.MinPasswordLength
minPasswordLength := form.config.App.Settings().EmailAuth.MinPasswordLength
return validation.ValidateStruct(form,
validation.Field(&form.Token, validation.Required, validation.By(form.checkToken)),
@@ -40,9 +70,9 @@ func (form *UserPasswordResetConfirm) checkToken(value any) error {
return nil // nothing to check
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
v,
form.app.Settings().UserPasswordResetToken.Secret,
form.config.App.Settings().UserPasswordResetToken.Secret,
)
if err != nil || user == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -58,9 +88,9 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
form.Token,
form.app.Settings().UserPasswordResetToken.Secret,
form.config.App.Settings().UserPasswordResetToken.Secret,
)
if err != nil {
return nil, err
@@ -70,7 +100,7 @@ func (form *UserPasswordResetConfirm) Submit() (*models.User, error) {
return nil, err
}
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserPasswordResetConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserPasswordResetConfirm(nil)
}
func TestUserPasswordResetConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+41 -11
View File
@@ -7,24 +7,54 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserPasswordResetRequest defines a user password reset request form.
// UserPasswordResetRequest specifies a user password reset request form.
type UserPasswordResetRequest struct {
app core.App
resendThreshold float64
config UserPasswordResetRequestConfig
Email string `form:"email" json:"email"`
}
// NewUserPasswordResetRequest creates new user password reset request form.
// UserPasswordResetRequestConfig is the [UserPasswordResetRequest]
// factory initializer config.
//
// NB! App is required struct member.
type UserPasswordResetRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewUserPasswordResetRequest creates a new [UserPasswordResetRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserPasswordResetRequestWithConfig] with explicitly set TxDao.
func NewUserPasswordResetRequest(app core.App) *UserPasswordResetRequest {
return &UserPasswordResetRequest{
app: app,
resendThreshold: 120, // 2 min
return NewUserPasswordResetRequestWithConfig(UserPasswordResetRequestConfig{
App: app,
ResendThreshold: 120, // 2 min
})
}
// NewUserPasswordResetRequestWithConfig creates a new [UserPasswordResetRequest]
// form with the provided config or panics on invalid configuration.
func NewUserPasswordResetRequestWithConfig(config UserPasswordResetRequestConfig) *UserPasswordResetRequest {
form := &UserPasswordResetRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,23 +78,23 @@ func (form *UserPasswordResetRequest) Submit() error {
return err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return err
}
now := time.Now().UTC()
lastResetSentAt := user.LastResetSentAt.Time()
if now.Sub(lastResetSentAt).Seconds() < form.resendThreshold {
if now.Sub(lastResetSentAt).Seconds() < form.config.ResendThreshold {
return errors.New("You've already requested a password reset.")
}
if err := mails.SendUserPasswordReset(form.app, user); err != nil {
if err := mails.SendUserPasswordReset(form.config.App, user); err != nil {
return err
}
// update last sent timestamp
user.LastResetSentAt = types.NowDateTime()
return form.app.Dao().SaveUser(user)
return form.config.TxDao.SaveUser(user)
}
+10
View File
@@ -11,6 +11,16 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUserPasswordResetRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserPasswordResetRequest(nil)
}
func TestUserPasswordResetRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+61 -19
View File
@@ -6,33 +6,63 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/forms/validators"
"github.com/pocketbase/pocketbase/models"
"github.com/pocketbase/pocketbase/tools/list"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserUpsert defines a user upsert (create/update) form.
// UserUpsert specifies a [models.User] upsert (create/update) form.
type UserUpsert struct {
app core.App
user *models.User
isCreate bool
config UserUpsertConfig
user *models.User
Id string `form:"id" json:"id"`
Email string `form:"email" json:"email"`
Password string `form:"password" json:"password"`
PasswordConfirm string `form:"passwordConfirm" json:"passwordConfirm"`
}
// NewUserUpsert creates new upsert form for the provided user model
// (pass an empty user model instance (`&models.User{}`) for create).
// UserUpsertConfig is the [UserUpsert] factory initializer config.
//
// NB! App is required struct member.
type UserUpsertConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserUpsert creates a new [UserUpsert] form with initializer
// config created from the provided [core.App] instance
// (for create you could pass a pointer to an empty User - `&models.User{}`).
//
// If you want to submit the form as part of another transaction, use
// [NewUserEmailChangeConfirmWithConfig] with explicitly set TxDao.
func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
return NewUserUpsertWithConfig(UserUpsertConfig{
App: app,
}, user)
}
// NewUserUpsertWithConfig creates a new [UserUpsert] form with the provided
// config and [models.User] instance or panics on invalid configuration
// (for create you could pass a pointer to an empty User - `&models.User{}`).
func NewUserUpsertWithConfig(config UserUpsertConfig, user *models.User) *UserUpsert {
form := &UserUpsert{
app: app,
user: user,
isCreate: !user.HasId(),
config: config,
user: user,
}
if form.config.App == nil || form.user == nil {
panic("Invalid initializer config or nil upsert model.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
// load defaults
form.Id = user.Id
form.Email = user.Email
return form
@@ -40,9 +70,15 @@ func NewUserUpsert(app core.App, user *models.User) *UserUpsert {
// Validate makes the form validatable by implementing [validation.Validatable] interface.
func (form *UserUpsert) Validate() error {
config := form.app.Settings()
return validation.ValidateStruct(form,
validation.Field(
&form.Id,
validation.When(
form.user.IsNew(),
validation.Length(models.DefaultIdLength, models.DefaultIdLength),
validation.Match(idRegex),
).Else(validation.In(form.user.Id)),
),
validation.Field(
&form.Email,
validation.Required,
@@ -53,12 +89,12 @@ func (form *UserUpsert) Validate() error {
),
validation.Field(
&form.Password,
validation.When(form.isCreate, validation.Required),
validation.Length(config.EmailAuth.MinPasswordLength, 100),
validation.When(form.user.IsNew(), validation.Required),
validation.Length(form.config.App.Settings().EmailAuth.MinPasswordLength, 100),
),
validation.Field(
&form.PasswordConfirm,
validation.When(form.isCreate || form.Password != "", validation.Required),
validation.When(form.user.IsNew() || form.Password != "", validation.Required),
validation.By(validators.Compare(form.Password)),
),
)
@@ -67,7 +103,7 @@ func (form *UserUpsert) Validate() error {
func (form *UserUpsert) checkUniqueEmail(value any) error {
v, _ := value.(string)
if v == "" || form.app.Dao().IsUserEmailUnique(v, form.user.Id) {
if v == "" || form.config.TxDao.IsUserEmailUnique(v, form.user.Id) {
return nil
}
@@ -81,8 +117,8 @@ func (form *UserUpsert) checkEmailDomain(value any) error {
}
domain := val[strings.LastIndex(val, "@")+1:]
only := form.app.Settings().EmailAuth.OnlyDomains
except := form.app.Settings().EmailAuth.ExceptDomains
only := form.config.App.Settings().EmailAuth.OnlyDomains
except := form.config.App.Settings().EmailAuth.ExceptDomains
// only domains check
if len(only) > 0 && !list.ExistInSlice(domain, only) {
@@ -110,7 +146,13 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
form.user.SetPassword(form.Password)
}
if !form.isCreate && form.Email != form.user.Email {
// custom insertion id can be set only on create
if form.user.IsNew() && form.Id != "" {
form.user.MarkAsNew()
form.user.SetId(form.Id)
}
if !form.user.IsNew() && form.Email != form.user.Email {
form.user.Verified = false
form.user.LastVerificationSentAt = types.DateTime{} // reset
}
@@ -118,6 +160,6 @@ func (form *UserUpsert) Submit(interceptors ...InterceptorFunc) error {
form.user.Email = form.Email
return runInterceptors(func() error {
return form.app.Dao().SaveUser(form.user)
return form.config.TxDao.SaveUser(form.user)
}, interceptors...)
}
+126
View File
@@ -3,6 +3,7 @@ package forms_test
import (
"encoding/json"
"errors"
"fmt"
"testing"
validation "github.com/go-ozzo/ozzo-validation/v4"
@@ -11,6 +12,29 @@ import (
"github.com/pocketbase/pocketbase/tests"
)
func TestUserUpsertPanic1(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserUpsert(nil, nil)
}
func TestUserUpsertPanic2(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserUpsert(app, nil)
}
func TestNewUserUpsert(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
@@ -304,3 +328,105 @@ func TestUserUpsertSubmitInterceptors(t *testing.T) {
t.Fatalf("Expected the form model to be filled before calling the interceptors")
}
}
func TestUserUpsertWithCustomId(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
existingUser, err := app.Dao().FindUserByEmail("test@example.com")
if err != nil {
t.Fatal(err)
}
scenarios := []struct {
name string
jsonData string
collection *models.User
expectError bool
}{
{
"empty data",
"{}",
&models.User{},
false,
},
{
"empty id",
`{"id":""}`,
&models.User{},
false,
},
{
"id < 15 chars",
`{"id":"a23"}`,
&models.User{},
true,
},
{
"id > 15 chars",
`{"id":"a234567890123456"}`,
&models.User{},
true,
},
{
"id = 15 chars (invalid chars)",
`{"id":"a@3456789012345"}`,
&models.User{},
true,
},
{
"id = 15 chars (valid chars)",
`{"id":"a23456789012345"}`,
&models.User{},
false,
},
{
"changing the id of an existing item",
`{"id":"b23456789012345"}`,
existingUser,
true,
},
{
"using the same existing item id",
`{"id":"` + existingUser.Id + `"}`,
existingUser,
false,
},
{
"skipping the id for existing item",
`{}`,
existingUser,
false,
},
}
for i, scenario := range scenarios {
form := forms.NewUserUpsert(app, scenario.collection)
if form.Email == "" {
form.Email = fmt.Sprintf("test_id_%d@example.com", i)
}
form.Password = "1234567890"
form.PasswordConfirm = form.Password
// load data
loadErr := json.Unmarshal([]byte(scenario.jsonData), form)
if loadErr != nil {
t.Errorf("[%s] Failed to load form data: %v", scenario.name, loadErr)
continue
}
submitErr := form.Submit()
hasErr := submitErr != nil
if hasErr != scenario.expectError {
t.Errorf("[%s] Expected hasErr to be %v, got %v (%v)", scenario.name, scenario.expectError, hasErr, submitErr)
}
if !hasErr && form.Id != "" {
_, err := app.Dao().FindUserById(form.Id)
if err != nil {
t.Errorf("[%s] Expected to find record with id %s, got %v", scenario.name, form.Id, err)
}
}
}
}
+40 -10
View File
@@ -3,21 +3,51 @@ package forms
import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/models"
)
// UserVerificationConfirm defines a user email confirmation form.
// UserVerificationConfirm specifies a user email verification confirmation form.
type UserVerificationConfirm struct {
app core.App
config UserVerificationConfirmConfig
Token string `form:"token" json:"token"`
}
// NewUserVerificationConfirm creates a new user email confirmation form.
// UserVerificationConfirmConfig is the [UserVerificationConfirm]
// factory initializer config.
//
// NB! App is required struct member.
type UserVerificationConfirmConfig struct {
App core.App
TxDao *daos.Dao
}
// NewUserVerificationConfirm creates a new [UserVerificationConfirm]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserVerificationConfirmWithConfig] with explicitly set TxDao.
func NewUserVerificationConfirm(app core.App) *UserVerificationConfirm {
return &UserVerificationConfirm{
app: app,
return NewUserVerificationConfirmWithConfig(UserVerificationConfirmConfig{
App: app,
})
}
// NewUserVerificationConfirmWithConfig creates a new [UserVerificationConfirmConfig]
// form with the provided config or panics on invalid configuration.
func NewUserVerificationConfirmWithConfig(config UserVerificationConfirmConfig) *UserVerificationConfirm {
form := &UserVerificationConfirm{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -33,9 +63,9 @@ func (form *UserVerificationConfirm) checkToken(value any) error {
return nil // nothing to check
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
v,
form.app.Settings().UserVerificationToken.Secret,
form.config.App.Settings().UserVerificationToken.Secret,
)
if err != nil || user == nil {
return validation.NewError("validation_invalid_token", "Invalid or expired token.")
@@ -51,9 +81,9 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
return nil, err
}
user, err := form.app.Dao().FindUserByToken(
user, err := form.config.TxDao.FindUserByToken(
form.Token,
form.app.Settings().UserVerificationToken.Secret,
form.config.App.Settings().UserVerificationToken.Secret,
)
if err != nil {
return nil, err
@@ -65,7 +95,7 @@ func (form *UserVerificationConfirm) Submit() (*models.User, error) {
user.Verified = true
if err := form.app.Dao().SaveUser(user); err != nil {
if err := form.config.TxDao.SaveUser(user); err != nil {
return nil, err
}
+10
View File
@@ -10,6 +10,16 @@ import (
"github.com/pocketbase/pocketbase/tools/security"
)
func TestUserVerificationConfirmPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserVerificationConfirm(nil)
}
func TestUserVerificationConfirmValidate(t *testing.T) {
app, _ := tests.NewTestApp()
defer app.Cleanup()
+40 -10
View File
@@ -7,24 +7,54 @@ import (
validation "github.com/go-ozzo/ozzo-validation/v4"
"github.com/go-ozzo/ozzo-validation/v4/is"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/daos"
"github.com/pocketbase/pocketbase/mails"
"github.com/pocketbase/pocketbase/tools/types"
)
// UserVerificationRequest defines a user email verification request form.
type UserVerificationRequest struct {
app core.App
resendThreshold float64
config UserVerificationRequestConfig
Email string `form:"email" json:"email"`
}
// NewUserVerificationRequest creates a new user email verification request form.
// UserVerificationRequestConfig is the [UserVerificationRequest]
// factory initializer config.
//
// NB! App is required struct member.
type UserVerificationRequestConfig struct {
App core.App
TxDao *daos.Dao
ResendThreshold float64 // in seconds
}
// NewUserVerificationRequest creates a new [UserVerificationRequest]
// form with initializer config created from the provided [core.App] instance.
//
// If you want to submit the form as part of another transaction, use
// [NewUserVerificationRequestWithConfig] with explicitly set TxDao.
func NewUserVerificationRequest(app core.App) *UserVerificationRequest {
return &UserVerificationRequest{
app: app,
resendThreshold: 120, // 2 min
return NewUserVerificationRequestWithConfig(UserVerificationRequestConfig{
App: app,
ResendThreshold: 120, // 2 min
})
}
// NewUserVerificationRequestWithConfig creates a new [UserVerificationRequest]
// form with the provided config or panics on invalid configuration.
func NewUserVerificationRequestWithConfig(config UserVerificationRequestConfig) *UserVerificationRequest {
form := &UserVerificationRequest{config: config}
if form.config.App == nil {
panic("Missing required config.App instance.")
}
if form.config.TxDao == nil {
form.config.TxDao = form.config.App.Dao()
}
return form
}
// Validate makes the form validatable by implementing [validation.Validatable] interface.
@@ -48,7 +78,7 @@ func (form *UserVerificationRequest) Submit() error {
return err
}
user, err := form.app.Dao().FindUserByEmail(form.Email)
user, err := form.config.TxDao.FindUserByEmail(form.Email)
if err != nil {
return err
}
@@ -59,16 +89,16 @@ func (form *UserVerificationRequest) Submit() error {
now := time.Now().UTC()
lastVerificationSentAt := user.LastVerificationSentAt.Time()
if (now.Sub(lastVerificationSentAt)).Seconds() < form.resendThreshold {
if (now.Sub(lastVerificationSentAt)).Seconds() < form.config.ResendThreshold {
return errors.New("A verification email was already sent.")
}
if err := mails.SendUserVerification(form.app, user); err != nil {
if err := mails.SendUserVerification(form.config.App, user); err != nil {
return err
}
// update last sent timestamp
user.LastVerificationSentAt = types.NowDateTime()
return form.app.Dao().SaveUser(user)
return form.config.TxDao.SaveUser(user)
}
+10
View File
@@ -11,6 +11,16 @@ import (
"github.com/pocketbase/pocketbase/tools/types"
)
func TestUserVerificationRequestPanic(t *testing.T) {
defer func() {
if recover() == nil {
t.Fatal("The form did not panic")
}
}()
forms.NewUserVerificationRequest(nil)
}
func TestUserVerificationRequestValidate(t *testing.T) {
testApp, _ := tests.NewTestApp()
defer testApp.Cleanup()
+2 -2
View File
@@ -16,8 +16,8 @@ const AdminPasswordResetBody = `
<p>Follow this link to reset your admin password for {{.AppName}}.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you did not request to reset your password, please ignore this email and the link will expire on its own.</i></p>
+2 -2
View File
@@ -14,8 +14,8 @@ const UserConfirmEmailChangeBody = `
<p>Hello,</p>
<p>Click on the button below to confirm your new email address.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Confirm new email</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Confirm new email</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you didn’t ask to change your email address, you can ignore this email.</i></p>
<p>
+2 -2
View File
@@ -14,8 +14,8 @@ const UserPasswordResetBody = `
<p>Hello,</p>
<p>Click on the button below to reset your password.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Reset password</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p><i>If you didn’t ask to reset your password, you can ignore this email.</i></p>
<p>
+2 -2
View File
@@ -15,8 +15,8 @@ const UserVerificationBody = `
<p>Thank you for joining us at {{.AppName}}.</p>
<p>Click on the button below to verify your email address.</p>
<p>
<a class="btn" href="{{.ActionUrl}}">Verify</a>
<a class="fallback-link" href="{{.ActionUrl}}">{{.ActionUrl}}</a>
<a class="btn" href="{{.ActionUrl}}" target="_blank" rel="noopener">Verify</a>
<a class="fallback-link" href="{{.ActionUrl}}" target="_blank" rel="noopener">{{.ActionUrl}}</a>
</p>
<p>
Thanks,<br/>
+5
View File
@@ -96,6 +96,7 @@ func init() {
UpdateRule: &profileOwnerRule,
Schema: schema.NewSchema(
&schema.SchemaField{
Id: "pbfielduser",
Name: models.ProfileCollectionUserFieldName,
Type: schema.FieldTypeUser,
Unique: true,
@@ -107,11 +108,13 @@ func init() {
},
},
&schema.SchemaField{
Id: "pbfieldname",
Name: "name",
Type: schema.FieldTypeText,
Options: &schema.TextOptions{},
},
&schema.SchemaField{
Id: "pbfieldavatar",
Name: "avatar",
Type: schema.FieldTypeFile,
Options: &schema.FileOptions{
@@ -128,6 +131,8 @@ func init() {
},
),
}
collection.Id = "systemprofiles0"
collection.MarkAsNew()
return daos.New(db).SaveCollection(collection)
}, func(db dbx.Builder) error {
+38 -9
View File
@@ -9,6 +9,9 @@ import (
"golang.org/x/crypto/bcrypt"
)
// DefaultIdLength is the default length of the generated model id.
const DefaultIdLength = 15
// ColumnValueMapper defines an interface for custom db model data serialization.
type ColumnValueMapper interface {
// ColumnValueMap returns the data to be used when persisting the model.
@@ -24,8 +27,12 @@ type FilesManager interface {
// Model defines an interface with common methods that all db models should have.
type Model interface {
TableName() string
IsNew() bool
MarkAsNew()
UnmarkAsNew()
HasId() bool
GetId() string
SetId(id string)
GetCreated() types.DateTime
GetUpdated() types.DateTime
RefreshId()
@@ -39,45 +46,67 @@ type Model interface {
// BaseModel defines common fields and methods used by all other models.
type BaseModel struct {
isNewFlag bool
Id string `db:"id" json:"id"`
Created types.DateTime `db:"created" json:"created"`
Updated types.DateTime `db:"updated" json:"updated"`
}
// HasId returns whether the model has a nonzero primary key (aka. id).
// HasId returns whether the model has a nonzero id.
func (m *BaseModel) HasId() bool {
return m.GetId() != ""
}
// GetId returns the model's id.
// GetId returns the model id.
func (m *BaseModel) GetId() string {
return m.Id
}
// GetCreated returns the model's Created datetime.
// SetId sets the model id to the provided string value.
func (m *BaseModel) SetId(id string) {
m.Id = id
}
// MarkAsNew sets the model isNewFlag enforcing [m.IsNew()] to be true.
func (m *BaseModel) MarkAsNew() {
m.isNewFlag = true
}
// UnmarkAsNew resets the model isNewFlag.
func (m *BaseModel) UnmarkAsNew() {
m.isNewFlag = false
}
// IsNew indicates what type of db query (insert or update)
// should be used with the model instance.
func (m *BaseModel) IsNew() bool {
return m.isNewFlag || !m.HasId()
}
// GetCreated returns the model Created datetime.
func (m *BaseModel) GetCreated() types.DateTime {
return m.Created
}
// GetUpdated returns the model's Updated datetime.
// GetUpdated returns the model Updated datetime.
func (m *BaseModel) GetUpdated() types.DateTime {
return m.Updated
}
// RefreshId generates and sets a new model id.
//
// The generated id is a cryptographically random 15 characters length string
// (could change in the future).
// The generated id is a cryptographically random 15 characters length string.
func (m *BaseModel) RefreshId() {
m.Id = security.RandomString(15)
m.Id = security.RandomString(DefaultIdLength)
}
// RefreshCreated updates the model's Created field with the current datetime.
// RefreshCreated updates the model Created field with the current datetime.
func (m *BaseModel) RefreshCreated() {
m.Created = types.NowDateTime()
}
// RefreshUpdated updates the model's Updated field with the current datetime.
// RefreshUpdated updates the model Updated field with the current datetime.
func (m *BaseModel) RefreshUpdated() {
m.Updated = types.NowDateTime()
}
+43 -13
View File
@@ -34,25 +34,55 @@ func TestBaseModelHasId(t *testing.T) {
}
}
func TestBaseModelGetId(t *testing.T) {
m0 := models.BaseModel{}
if m0.GetId() != "" {
t.Fatalf("Expected zero id value, got %v", m0.GetId())
func TestBaseModelId(t *testing.T) {
m := models.BaseModel{}
if m.GetId() != "" {
t.Fatalf("Expected empty id value, got %v", m.GetId())
}
id := "abc"
m1 := models.BaseModel{Id: id}
if m1.GetId() != id {
t.Fatalf("Expected id %v, got %v", id, m1.GetId())
m.SetId("test")
if m.GetId() != "test" {
t.Fatalf("Expected %q id, got %v", "test", m.GetId())
}
m.RefreshId()
if len(m.GetId()) != 15 {
t.Fatalf("Expected 15 chars id, got %v", m.GetId())
}
}
func TestBaseModelRefreshId(t *testing.T) {
m := models.BaseModel{}
m.RefreshId()
func TestBaseModelIsNew(t *testing.T) {
m0 := models.BaseModel{}
m1 := models.BaseModel{Id: ""}
m2 := models.BaseModel{Id: "test"}
m3 := models.BaseModel{}
m3.MarkAsNew()
m4 := models.BaseModel{Id: "test"}
m4.MarkAsNew()
m5 := models.BaseModel{Id: "test"}
m5.MarkAsNew()
m5.UnmarkAsNew()
if m.GetId() == "" {
t.Fatalf("Expected nonempty id value, got %v", m.GetId())
scenarios := []struct {
model models.BaseModel
expected bool
}{
{m0, true},
{m1, true},
{m2, false},
{m3, true},
{m4, true},
{m5, false},
}
for i, s := range scenarios {
result := s.model.IsNew()
if result != s.expected {
t.Errorf("(%d) Expected IsNew %v, got %v", i, s.expected, result)
}
}
}
+10
View File
@@ -313,6 +313,16 @@ func NewTestApp() (*TestApp, error) {
return nil
})
t.OnCollectionsBeforeImportRequest().Add(func(e *core.CollectionsImportEvent) error {
t.EventCalls["OnCollectionsBeforeImportRequest"]++
return nil
})
t.OnCollectionsAfterImportRequest().Add(func(e *core.CollectionsImportEvent) error {
t.EventCalls["OnCollectionsAfterImportRequest"]++
return nil
})
t.OnAdminsListRequest().Add(func(e *core.AdminsListEvent) error {
t.EventCalls["OnAdminsListRequest"]++
return nil
Binary file not shown.
+1 -1
View File
@@ -21,7 +21,7 @@ type Github struct {
// NewGithubProvider creates new Github provider instance with some defaults.
func NewGithubProvider() *Github {
return &Github{&baseProvider{
scopes: []string{"user"},
scopes: []string{"read:user", "user:email"},
authUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userApiUrl: "https://api.github.com/user",
+8 -2
View File
@@ -178,8 +178,14 @@ func (s *System) DeletePrefix(prefix string) []error {
}
var inlineServeContentTypes = []string{
"image/png", "image/jpg", "image/jpeg", "image/gif",
"video/mp4", "video/3gpp", "video/quicktime", " video/x-ms-wmv",
// image
"image/png", "image/jpg", "image/jpeg", "image/gif", "image/webp", "image/x-icon", "image/bmp",
// video
"video/webm", "video/mp4", "video/3gpp", "video/quicktime", "video/x-ms-wmv",
// audio
"audio/basic", "audio/aiff", "audio/mpeg", "audio/midi", "audio/wave",
// document
"application/pdf",
}
// Serve serves the file at fileKey location to an HTTP response.
+13 -11
View File
@@ -177,21 +177,22 @@ func TestFileSystemServe(t *testing.T) {
"test_name.txt",
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.txt",
"Content-Type": "application/octet-stream",
"Content-Length": "0",
"Content-Disposition": "attachment; filename=test_name.txt",
"Content-Type": "application/octet-stream",
"Content-Length": "0",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
// png inline
{
// svg exception
// png inline
"image.png",
"test_name.png",
false,
map[string]string{
"Content-Disposition": "inline; filename=test_name.png",
"Content-Type": "image/png",
"Content-Length": "73",
"Content-Disposition": "inline; filename=test_name.png",
"Content-Type": "image/png",
"Content-Length": "73",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
{
@@ -200,9 +201,10 @@ func TestFileSystemServe(t *testing.T) {
"test_name.svg",
false,
map[string]string{
"Content-Disposition": "attachment; filename=test_name.svg",
"Content-Type": "image/svg+xml",
"Content-Length": "0",
"Content-Disposition": "attachment; filename=test_name.svg",
"Content-Type": "image/svg+xml",
"Content-Length": "0",
"Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
},
},
}
+2 -57
View File
@@ -2,14 +2,11 @@ package migrate
import (
"fmt"
"os"
"path"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/fatih/color"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/tools/inflector"
"github.com/spf13/cast"
)
@@ -40,9 +37,8 @@ func NewRunner(db *dbx.DB, migrationsList MigrationsList) (*Runner, error) {
// Run interactively executes the current runner with the provided args.
//
// The following commands are supported:
// - up - applies all migrations
// - down [n] - reverts the last n applied migrations
// - create NEW_MIGRATION_NAME - create NEW_MIGRATION_NAME.go file from a migration template
// - up - applies all migrations
// - down [n] - reverts the last n applied migrations
func (r *Runner) Run(args ...string) error {
cmd := "up"
if len(args) > 0 {
@@ -100,57 +96,6 @@ func (r *Runner) Run(args ...string) error {
}
}
return nil
case "create":
if len(args) < 2 {
return fmt.Errorf("Missing migration file name")
}
name := args[1]
var dir string
if len(args) == 3 {
dir = args[2]
}
if dir == "" {
// If not specified, auto point to the default migrations folder.
//
// NB!
// Since the create command makes sense only during development,
// it is expected the user to be in the app working directory
// and to be using `go run ...`
wd, err := os.Getwd()
if err != nil {
return err
}
dir = path.Join(wd, "migrations")
}
resultFilePath := path.Join(
dir,
fmt.Sprintf("%d_%s.go", time.Now().Unix(), inflector.Snakecase(name)),
)
confirm := false
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you really want to create migration %q?", resultFilePath),
}
survey.AskOne(prompt, &confirm)
if !confirm {
fmt.Println("The command has been cancelled")
return nil
}
// ensure that migrations dir exist
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return err
}
if err := os.WriteFile(resultFilePath, []byte(createTemplateContent), 0644); err != nil {
return fmt.Errorf("Failed to save migration file %q\n", resultFilePath)
}
fmt.Printf("Successfully created file %q\n", resultFilePath)
return nil
default:
return fmt.Errorf("Unsupported command: %q\n", cmd)
-21
View File
@@ -1,21 +0,0 @@
package migrate
const createTemplateContent = `package migrations
import (
"github.com/pocketbase/dbx"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(db dbx.Builder) error {
// add up queries...
return nil
}, func(db dbx.Builder) error {
// add down queries...
return nil
})
}
`
+7 -1
View File
@@ -147,11 +147,17 @@ func (f FilterData) resolveToken(token fexpr.Token, fieldResolver FieldResolver)
}
return name, params, err
case fexpr.TokenNumber, fexpr.TokenText:
case fexpr.TokenText:
placeholder := "t" + security.RandomString(7)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: token.Literal}
return name, params, nil
case fexpr.TokenNumber:
placeholder := "t" + security.RandomString(7)
name := fmt.Sprintf("{:%s}", placeholder)
params := dbx.Params{placeholder: cast.ToFloat64(token.Literal)}
return name, params, nil
}
+5 -1
View File
@@ -28,6 +28,7 @@ type Result struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalItems int `json:"totalItems"`
TotalPages int `json:"totalPages"`
Items any `json:"items"`
}
@@ -213,10 +214,12 @@ func (s *Provider) Exec(items any) (*Result, error) {
s.perPage = MaxPerPage
}
totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage)))
// normalize page according to the total count
if s.page <= 0 || totalCount == 0 {
s.page = 1
} else if totalPages := int(math.Ceil(float64(totalCount) / float64(s.perPage))); s.page > totalPages {
} else if s.page > totalPages {
s.page = totalPages
}
@@ -233,6 +236,7 @@ func (s *Provider) Exec(items any) (*Result, error) {
Page: s.page,
PerPage: s.perPage,
TotalItems: int(totalCount),
TotalPages: totalPages,
Items: items,
}, nil
}
+10 -10
View File
@@ -236,7 +236,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{},
"",
false,
`{"page":1,"perPage":10,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":10,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 10",
@@ -250,7 +250,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{},
"",
false,
`{"page":1,"perPage":30,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":30,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 30",
@@ -286,10 +286,10 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{"test2 != null", "test1 >= 2"},
"",
false,
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":` + fmt.Sprint(MaxPerPage) + `,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= '2') ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
"SELECT COUNT(*) FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC",
"SELECT * FROM `test` WHERE ((NOT (`test1` IS NULL)) AND (COALESCE(test2, '') != COALESCE(null, ''))) AND (test1 >= 2) ORDER BY `test1` ASC, `test2` DESC LIMIT 200",
},
},
// valid sort and filter fields (zero results)
@@ -300,7 +300,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{"test3 != ''"},
"",
false,
`{"page":1,"perPage":10,"totalItems":0,"items":[]}`,
`{"page":1,"perPage":10,"totalItems":0,"totalPages":0,"items":[]}`,
[]string{
"SELECT COUNT(*) FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC",
"SELECT * FROM `test` WHERE (NOT (`test1` IS NULL)) AND (COALESCE(test3, '') != COALESCE('', '')) ORDER BY `test1` ASC, `test3` ASC LIMIT 10",
@@ -314,7 +314,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{},
"",
false,
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(*) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
@@ -328,7 +328,7 @@ func TestProviderExecNonEmptyQuery(t *testing.T) {
[]FilterData{},
"test.test1",
false,
`{"page":2,"perPage":1,"totalItems":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":2,"perPage":1,"totalItems":2,"totalPages":2,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
[]string{
"SELECT COUNT(DISTINCT(test.test1)) FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC",
"SELECT * FROM `test` WHERE NOT (`test1` IS NULL) ORDER BY `test1` ASC LIMIT 1 OFFSET 1",
@@ -403,7 +403,7 @@ func TestProviderParseAndExec(t *testing.T) {
{
"",
false,
`{"page":1,"perPage":123,"totalItems":2,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":123,"totalItems":2,"totalPages":1,"items":[{"test1":1,"test2":"test2.1","test3":""},{"test1":2,"test2":"test2.2","test3":""}]}`,
},
// invalid query
{
@@ -439,7 +439,7 @@ func TestProviderParseAndExec(t *testing.T) {
{
"page=3&perPage=555&filter=test1>1&sort=-test2,test3",
false,
`{"page":1,"perPage":200,"totalItems":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
`{"page":1,"perPage":200,"totalItems":1,"totalPages":1,"items":[{"test1":2,"test2":"test2.2","test3":""}]}`,
},
}
+6 -4
View File
@@ -1,5 +1,7 @@
# all environments should start with 'PB_' prefix
PB_BACKEND_URL = /
PB_PROFILE_COLLECTION = profiles
PB_RULES_SYNTAX_DOCS = https://pocketbase.io/docs/manage-collections#rules-filters-syntax
PB_INSTALLER_PARAM = installer
PB_BACKEND_URL = "../"
PB_PROFILE_COLLECTION = "profiles"
PB_INSTALLER_PARAM = "installer"
PB_RULES_SYNTAX_DOCS = "https://pocketbase.io/docs/manage-collections#rules-filters-syntax"
PB_RELEASES = "https://github.com/pocketbase/pocketbase/releases"
PB_VERSION = "v0.4.2"
+1 -1
View File
@@ -1 +1 @@
PB_BACKEND_URL = http://localhost:8090
PB_BACKEND_URL = "http://127.0.0.1:8090"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,2 +1,2 @@
import{S as E,i as G,s as I,F as K,c as F,m as B,t as H,a as N,d as T,C as M,q as J,e as c,w as q,b as k,f as u,r as L,g as b,h as _,u as h,v as O,j as Q,l as U,o as w,A as V,p as W,B as X,D as Y,x as Z,z as S}from"./index.38300823.js";function y(f){let e,o,s;return{c(){e=q("for "),o=c("strong"),s=q(f[3]),u(o,"class","txt-nowrap")},m(l,t){b(l,e,t),b(l,o,t),_(o,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&w(e),l&&w(o)}}}function x(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0,t.autofocus=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[0]),t.focus(),p||(d=h(t,"input",f[6]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&1&&t.value!==n[0]&&S(t,n[0])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function ee(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password confirm"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[1]),p||(d=h(t,"input",f[7]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&2&&t.value!==n[1]&&S(t,n[1])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function te(f){let e,o,s,l,t,r,p,d,n,i,g,R,C,v,P,A,j,m=f[3]&&y(f);return r=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),d=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),{c(){e=c("form"),o=c("div"),s=c("h4"),l=q(`Reset your admin password
`),m&&m.c(),t=k(),F(r.$$.fragment),p=k(),F(d.$$.fragment),n=k(),i=c("button"),g=c("span"),g.textContent="Set new password",R=k(),C=c("div"),v=c("a"),v.textContent="Back to login",u(s,"class","m-b-xs"),u(o,"class","content txt-center m-b-sm"),u(g,"class","txt"),u(i,"type","submit"),u(i,"class","btn btn-lg btn-block"),i.disabled=f[2],L(i,"btn-loading",f[2]),u(e,"class","m-b-base"),u(v,"href","/login"),u(v,"class","link-hint"),u(C,"class","content txt-center")},m(a,$){b(a,e,$),_(e,o),_(o,s),_(s,l),m&&m.m(s,null),_(e,t),B(r,e,null),_(e,p),B(d,e,null),_(e,n),_(e,i),_(i,g),b(a,R,$),b(a,C,$),_(C,v),P=!0,A||(j=[h(e,"submit",O(f[4])),Q(U.call(null,v))],A=!0)},p(a,$){a[3]?m?m.p(a,$):(m=y(a),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const z={};$&769&&(z.$$scope={dirty:$,ctx:a}),r.$set(z);const D={};$&770&&(D.$$scope={dirty:$,ctx:a}),d.$set(D),(!P||$&4)&&(i.disabled=a[2]),$&4&&L(i,"btn-loading",a[2])},i(a){P||(H(r.$$.fragment,a),H(d.$$.fragment,a),P=!0)},o(a){N(r.$$.fragment,a),N(d.$$.fragment,a),P=!1},d(a){a&&w(e),m&&m.d(),T(r),T(d),a&&w(R),a&&w(C),A=!1,V(j)}}}function se(f){let e,o;return e=new K({props:{$$slots:{default:[te]},$$scope:{ctx:f}}}),{c(){F(e.$$.fragment)},m(s,l){B(e,s,l),o=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){o||(H(e.$$.fragment,s),o=!0)},o(s){N(e.$$.fragment,s),o=!1},d(s){T(e,s)}}}function le(f,e,o){let s,{params:l}=e,t="",r="",p=!1;async function d(){if(!p){o(2,p=!0);try{await W.Admins.confirmPasswordReset(l==null?void 0:l.token,t,r),X("Successfully set a new admin password."),Y("/")}catch(g){W.errorResponseHandler(g)}o(2,p=!1)}}function n(){t=this.value,o(0,t)}function i(){r=this.value,o(1,r)}return f.$$set=g=>{"params"in g&&o(5,l=g.params)},f.$$.update=()=>{f.$$.dirty&32&&o(3,s=M.getJWTPayload(l==null?void 0:l.token).email||"")},[t,r,p,s,d,l,n,i]}class ae extends E{constructor(e){super(),G(this,e,le,se,I,{params:5})}}export{ae as default};
import{S as E,i as G,s as I,F as K,c as A,m as B,t as H,a as N,d as T,C as M,q as J,e as c,w as q,b as k,f as u,r as L,g as b,h as _,u as h,v as O,j as Q,l as U,o as w,A as V,p as W,B as X,D as Y,x as Z,z as S}from"./index.02c04c04.js";function y(f){let e,o,s;return{c(){e=q("for "),o=c("strong"),s=q(f[3]),u(o,"class","txt-nowrap")},m(l,t){b(l,e,t),b(l,o,t),_(o,s)},p(l,t){t&8&&Z(s,l[3])},d(l){l&&w(e),l&&w(o)}}}function x(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0,t.autofocus=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[0]),t.focus(),p||(d=h(t,"input",f[6]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&1&&t.value!==n[0]&&S(t,n[0])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function ee(f){let e,o,s,l,t,r,p,d;return{c(){e=c("label"),o=q("New password confirm"),l=k(),t=c("input"),u(e,"for",s=f[8]),u(t,"type","password"),u(t,"id",r=f[8]),t.required=!0},m(n,i){b(n,e,i),_(e,o),b(n,l,i),b(n,t,i),S(t,f[1]),p||(d=h(t,"input",f[7]),p=!0)},p(n,i){i&256&&s!==(s=n[8])&&u(e,"for",s),i&256&&r!==(r=n[8])&&u(t,"id",r),i&2&&t.value!==n[1]&&S(t,n[1])},d(n){n&&w(e),n&&w(l),n&&w(t),p=!1,d()}}}function te(f){let e,o,s,l,t,r,p,d,n,i,g,R,C,v,P,F,j,m=f[3]&&y(f);return r=new J({props:{class:"form-field required",name:"password",$$slots:{default:[x,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),d=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[ee,({uniqueId:a})=>({8:a}),({uniqueId:a})=>a?256:0]},$$scope:{ctx:f}}}),{c(){e=c("form"),o=c("div"),s=c("h4"),l=q(`Reset your admin password
`),m&&m.c(),t=k(),A(r.$$.fragment),p=k(),A(d.$$.fragment),n=k(),i=c("button"),g=c("span"),g.textContent="Set new password",R=k(),C=c("div"),v=c("a"),v.textContent="Back to login",u(s,"class","m-b-xs"),u(o,"class","content txt-center m-b-sm"),u(g,"class","txt"),u(i,"type","submit"),u(i,"class","btn btn-lg btn-block"),i.disabled=f[2],L(i,"btn-loading",f[2]),u(e,"class","m-b-base"),u(v,"href","/login"),u(v,"class","link-hint"),u(C,"class","content txt-center")},m(a,$){b(a,e,$),_(e,o),_(o,s),_(s,l),m&&m.m(s,null),_(e,t),B(r,e,null),_(e,p),B(d,e,null),_(e,n),_(e,i),_(i,g),b(a,R,$),b(a,C,$),_(C,v),P=!0,F||(j=[h(e,"submit",O(f[4])),Q(U.call(null,v))],F=!0)},p(a,$){a[3]?m?m.p(a,$):(m=y(a),m.c(),m.m(s,null)):m&&(m.d(1),m=null);const z={};$&769&&(z.$$scope={dirty:$,ctx:a}),r.$set(z);const D={};$&770&&(D.$$scope={dirty:$,ctx:a}),d.$set(D),(!P||$&4)&&(i.disabled=a[2]),$&4&&L(i,"btn-loading",a[2])},i(a){P||(H(r.$$.fragment,a),H(d.$$.fragment,a),P=!0)},o(a){N(r.$$.fragment,a),N(d.$$.fragment,a),P=!1},d(a){a&&w(e),m&&m.d(),T(r),T(d),a&&w(R),a&&w(C),F=!1,V(j)}}}function se(f){let e,o;return e=new K({props:{$$slots:{default:[te]},$$scope:{ctx:f}}}),{c(){A(e.$$.fragment)},m(s,l){B(e,s,l),o=!0},p(s,[l]){const t={};l&527&&(t.$$scope={dirty:l,ctx:s}),e.$set(t)},i(s){o||(H(e.$$.fragment,s),o=!0)},o(s){N(e.$$.fragment,s),o=!1},d(s){T(e,s)}}}function le(f,e,o){let s,{params:l}=e,t="",r="",p=!1;async function d(){if(!p){o(2,p=!0);try{await W.admins.confirmPasswordReset(l==null?void 0:l.token,t,r),X("Successfully set a new admin password."),Y("/")}catch(g){W.errorResponseHandler(g)}o(2,p=!1)}}function n(){t=this.value,o(0,t)}function i(){r=this.value,o(1,r)}return f.$$set=g=>{"params"in g&&o(5,l=g.params)},f.$$.update=()=>{f.$$.dirty&32&&o(3,s=M.getJWTPayload(l==null?void 0:l.token).email||"")},[t,r,p,s,d,l,n,i]}class ae extends E{constructor(e){super(),G(this,e,le,se,I,{params:5})}}export{ae as default};
@@ -1,2 +1,2 @@
import{S as E,i as M,s as T,F as j,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as z,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as A,v as I,w as h,x as J,y as P,z as R}from"./index.38300823.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`<h4 class="m-b-xs">Forgotten admin password</h4>
<p>Enter the email associated with your account and we\u2019ll send you a recovery link:</p>`,n=g(),H(l.$$.fragment),t=g(),o=_("button"),f=_("i"),m=g(),i=_("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(i,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=c[1],F(o,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),L(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=A(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(o.disabled=r[1]),$&2&&F(o,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),S(l),b=!1,u()}}}function O(c){let e,s,n,l,t,o,f,m,i;return{c(){e=_("div"),s=_("div"),s.innerHTML='<i class="ri-checkbox-circle-line"></i>',n=g(),l=_("div"),t=_("p"),o=h("Check "),f=_("strong"),m=h(c[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,f),d(f,m),d(t,i)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,o,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",o=c[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),R(t,c[0]),t.focus(),f||(m=A(t,"input",c[4]),f=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&o!==(o=i[5])&&p(t,"id",o),a&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&v(e),i&&v(l),i&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,o,f,m;const i=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=i[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),o=!0,f||(m=z(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=i[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){o||(w(s),o=!0)},o(u){y(s),o=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new j({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){H(e.$$.fragment)},m(n,l){L(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){S(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.Admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.errorResponseHandler(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,o,f]}class Y extends E{constructor(e){super(),M(this,e,W,V,T,{})}}export{Y as default};
import{S as M,i as T,s as j,F as z,c as H,m as L,t as w,a as y,d as S,b as g,e as _,f as p,g as k,h as d,j as A,l as B,k as N,n as D,o as v,p as C,q as G,r as F,u as E,v as I,w as h,x as J,y as P,z as R}from"./index.02c04c04.js";function K(c){let e,s,n,l,t,o,f,m,i,a,b,u;return l=new G({props:{class:"form-field required",name:"email",$$slots:{default:[Q,({uniqueId:r})=>({5:r}),({uniqueId:r})=>r?32:0]},$$scope:{ctx:c}}}),{c(){e=_("form"),s=_("div"),s.innerHTML=`<h4 class="m-b-xs">Forgotten admin password</h4>
<p>Enter the email associated with your account and we\u2019ll send you a recovery link:</p>`,n=g(),H(l.$$.fragment),t=g(),o=_("button"),f=_("i"),m=g(),i=_("span"),i.textContent="Send recovery link",p(s,"class","content txt-center m-b-sm"),p(f,"class","ri-mail-send-line"),p(i,"class","txt"),p(o,"type","submit"),p(o,"class","btn btn-lg btn-block"),o.disabled=c[1],F(o,"btn-loading",c[1]),p(e,"class","m-b-base")},m(r,$){k(r,e,$),d(e,s),d(e,n),L(l,e,null),d(e,t),d(e,o),d(o,f),d(o,m),d(o,i),a=!0,b||(u=E(e,"submit",I(c[3])),b=!0)},p(r,$){const q={};$&97&&(q.$$scope={dirty:$,ctx:r}),l.$set(q),(!a||$&2)&&(o.disabled=r[1]),$&2&&F(o,"btn-loading",r[1])},i(r){a||(w(l.$$.fragment,r),a=!0)},o(r){y(l.$$.fragment,r),a=!1},d(r){r&&v(e),S(l),b=!1,u()}}}function O(c){let e,s,n,l,t,o,f,m,i;return{c(){e=_("div"),s=_("div"),s.innerHTML='<i class="ri-checkbox-circle-line"></i>',n=g(),l=_("div"),t=_("p"),o=h("Check "),f=_("strong"),m=h(c[0]),i=h(" for the recovery link."),p(s,"class","icon"),p(f,"class","txt-nowrap"),p(l,"class","content"),p(e,"class","alert alert-success")},m(a,b){k(a,e,b),d(e,s),d(e,n),d(e,l),d(l,t),d(t,o),d(t,f),d(f,m),d(t,i)},p(a,b){b&1&&J(m,a[0])},i:P,o:P,d(a){a&&v(e)}}}function Q(c){let e,s,n,l,t,o,f,m;return{c(){e=_("label"),s=h("Email"),l=g(),t=_("input"),p(e,"for",n=c[5]),p(t,"type","email"),p(t,"id",o=c[5]),t.required=!0,t.autofocus=!0},m(i,a){k(i,e,a),d(e,s),k(i,l,a),k(i,t,a),R(t,c[0]),t.focus(),f||(m=E(t,"input",c[4]),f=!0)},p(i,a){a&32&&n!==(n=i[5])&&p(e,"for",n),a&32&&o!==(o=i[5])&&p(t,"id",o),a&1&&t.value!==i[0]&&R(t,i[0])},d(i){i&&v(e),i&&v(l),i&&v(t),f=!1,m()}}}function U(c){let e,s,n,l,t,o,f,m;const i=[O,K],a=[];function b(u,r){return u[2]?0:1}return e=b(c),s=a[e]=i[e](c),{c(){s.c(),n=g(),l=_("div"),t=_("a"),t.textContent="Back to login",p(t,"href","/login"),p(t,"class","link-hint"),p(l,"class","content txt-center")},m(u,r){a[e].m(u,r),k(u,n,r),k(u,l,r),d(l,t),o=!0,f||(m=A(B.call(null,t)),f=!0)},p(u,r){let $=e;e=b(u),e===$?a[e].p(u,r):(N(),y(a[$],1,1,()=>{a[$]=null}),D(),s=a[e],s?s.p(u,r):(s=a[e]=i[e](u),s.c()),w(s,1),s.m(n.parentNode,n))},i(u){o||(w(s),o=!0)},o(u){y(s),o=!1},d(u){a[e].d(u),u&&v(n),u&&v(l),f=!1,m()}}}function V(c){let e,s;return e=new z({props:{$$slots:{default:[U]},$$scope:{ctx:c}}}),{c(){H(e.$$.fragment)},m(n,l){L(e,n,l),s=!0},p(n,[l]){const t={};l&71&&(t.$$scope={dirty:l,ctx:n}),e.$set(t)},i(n){s||(w(e.$$.fragment,n),s=!0)},o(n){y(e.$$.fragment,n),s=!1},d(n){S(e,n)}}}function W(c,e,s){let n="",l=!1,t=!1;async function o(){if(!l){s(1,l=!0);try{await C.admins.requestPasswordReset(n),s(2,t=!0)}catch(m){C.errorResponseHandler(m)}s(1,l=!1)}}function f(){n=this.value,s(0,n)}return[n,l,t,o,f]}class Y extends M{constructor(e){super(),T(this,e,W,V,j,{})}}export{Y as default};
@@ -1,4 +1,4 @@
import{S as J,i as M,s as N,F as R,c as S,m as U,t as $,a as v,d as z,C as W,E as Y,g as _,k as j,n as A,o as b,p as F,q as B,e as m,w as y,b as C,f as d,r as H,h as k,u as E,v as D,y as h,x as G,z as T}from"./index.38300823.js";function I(r){let e,s,t,l,n,o,c,a,i,u,g,q,p=r[3]&&L(r);return o=new B({props:{class:"form-field required",name:"password",$$slots:{default:[O,({uniqueId:f})=>({8:f}),({uniqueId:f})=>f?256:0]},$$scope:{ctx:r}}}),{c(){e=m("form"),s=m("div"),t=m("h4"),l=y(`Type your password to confirm changing your email address
`),p&&p.c(),n=C(),S(o.$$.fragment),c=C(),a=m("button"),i=m("span"),i.textContent="Confirm new email",d(t,"class","m-b-xs"),d(s,"class","content txt-center m-b-sm"),d(i,"class","txt"),d(a,"type","submit"),d(a,"class","btn btn-lg btn-block"),a.disabled=r[1],H(a,"btn-loading",r[1])},m(f,w){_(f,e,w),k(e,s),k(s,t),k(t,l),p&&p.m(t,null),k(e,n),U(o,e,null),k(e,c),k(e,a),k(a,i),u=!0,g||(q=E(e,"submit",D(r[4])),g=!0)},p(f,w){f[3]?p?p.p(f,w):(p=L(f),p.c(),p.m(t,null)):p&&(p.d(1),p=null);const P={};w&769&&(P.$$scope={dirty:w,ctx:f}),o.$set(P),(!u||w&2)&&(a.disabled=f[1]),w&2&&H(a,"btn-loading",f[1])},i(f){u||($(o.$$.fragment,f),u=!0)},o(f){v(o.$$.fragment,f),u=!1},d(f){f&&b(e),p&&p.d(),z(o),g=!1,q()}}}function K(r){let e,s,t,l,n;return{c(){e=m("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
import{S as M,i as N,s as R,F as U,c as S,m as z,t as $,a as v,d as J,C as W,E as Y,g as _,k as j,n as A,o as b,p as F,q as B,e as m,w as y,b as C,f as d,r as H,h as k,u as E,v as D,y as h,x as G,z as T}from"./index.02c04c04.js";function I(r){let e,s,t,l,n,o,c,a,i,u,g,q,p=r[3]&&L(r);return o=new B({props:{class:"form-field required",name:"password",$$slots:{default:[O,({uniqueId:f})=>({8:f}),({uniqueId:f})=>f?256:0]},$$scope:{ctx:r}}}),{c(){e=m("form"),s=m("div"),t=m("h4"),l=y(`Type your password to confirm changing your email address
`),p&&p.c(),n=C(),S(o.$$.fragment),c=C(),a=m("button"),i=m("span"),i.textContent="Confirm new email",d(t,"class","m-b-xs"),d(s,"class","content txt-center m-b-sm"),d(i,"class","txt"),d(a,"type","submit"),d(a,"class","btn btn-lg btn-block"),a.disabled=r[1],H(a,"btn-loading",r[1])},m(f,w){_(f,e,w),k(e,s),k(s,t),k(t,l),p&&p.m(t,null),k(e,n),z(o,e,null),k(e,c),k(e,a),k(a,i),u=!0,g||(q=E(e,"submit",D(r[4])),g=!0)},p(f,w){f[3]?p?p.p(f,w):(p=L(f),p.c(),p.m(t,null)):p&&(p.d(1),p=null);const P={};w&769&&(P.$$scope={dirty:w,ctx:f}),o.$set(P),(!u||w&2)&&(a.disabled=f[1]),w&2&&H(a,"btn-loading",f[1])},i(f){u||($(o.$$.fragment,f),u=!0)},o(f){v(o.$$.fragment,f),u=!1},d(f){f&&b(e),p&&p.d(),J(o),g=!1,q()}}}function K(r){let e,s,t,l,n;return{c(){e=m("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Email address changed</p>
<p>You can now sign in with your new email address.</p></div>`,s=C(),t=m("button"),t.textContent="Close",d(e,"class","alert alert-success"),d(t,"type","button"),d(t,"class","btn btn-secondary btn-block")},m(o,c){_(o,e,c),_(o,s,c),_(o,t,c),l||(n=E(t,"click",r[6]),l=!0)},p:h,i:h,o:h,d(o){o&&b(e),o&&b(s),o&&b(t),l=!1,n()}}}function L(r){let e,s,t;return{c(){e=y("to "),s=m("strong"),t=y(r[3]),d(s,"class","txt-nowrap")},m(l,n){_(l,e,n),_(l,s,n),k(s,t)},p(l,n){n&8&&G(t,l[3])},d(l){l&&b(e),l&&b(s)}}}function O(r){let e,s,t,l,n,o,c,a;return{c(){e=m("label"),s=y("Password"),l=C(),n=m("input"),d(e,"for",t=r[8]),d(n,"type","password"),d(n,"id",o=r[8]),n.required=!0,n.autofocus=!0},m(i,u){_(i,e,u),k(e,s),_(i,l,u),_(i,n,u),T(n,r[0]),n.focus(),c||(a=E(n,"input",r[7]),c=!0)},p(i,u){u&256&&t!==(t=i[8])&&d(e,"for",t),u&256&&o!==(o=i[8])&&d(n,"id",o),u&1&&n.value!==i[0]&&T(n,i[0])},d(i){i&&b(e),i&&b(l),i&&b(n),c=!1,a()}}}function Q(r){let e,s,t,l;const n=[K,I],o=[];function c(a,i){return a[2]?0:1}return e=c(r),s=o[e]=n[e](r),{c(){s.c(),t=Y()},m(a,i){o[e].m(a,i),_(a,t,i),l=!0},p(a,i){let u=e;e=c(a),e===u?o[e].p(a,i):(j(),v(o[u],1,1,()=>{o[u]=null}),A(),s=o[e],s?s.p(a,i):(s=o[e]=n[e](a),s.c()),$(s,1),s.m(t.parentNode,t))},i(a){l||($(s),l=!0)},o(a){v(s),l=!1},d(a){o[e].d(a),a&&b(t)}}}function V(r){let e,s;return e=new R({props:{nobranding:!0,$$slots:{default:[Q]},$$scope:{ctx:r}}}),{c(){S(e.$$.fragment)},m(t,l){U(e,t,l),s=!0},p(t,[l]){const n={};l&527&&(n.$$scope={dirty:l,ctx:t}),e.$set(n)},i(t){s||($(e.$$.fragment,t),s=!0)},o(t){v(e.$$.fragment,t),s=!1},d(t){z(e,t)}}}function X(r,e,s){let t,{params:l}=e,n="",o=!1,c=!1;async function a(){if(!o){s(1,o=!0);try{await F.Users.confirmEmailChange(l==null?void 0:l.token,n),s(2,c=!0)}catch(g){F.errorResponseHandler(g)}s(1,o=!1)}}const i=()=>window.close();function u(){n=this.value,s(0,n)}return r.$$set=g=>{"params"in g&&s(5,l=g.params)},r.$$.update=()=>{r.$$.dirty&32&&s(3,t=W.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[n,o,c,t,a,l,i,u]}class x extends J{constructor(e){super(),M(this,e,X,V,N,{params:5})}}export{x as default};
<p>You can now sign in with your new email address.</p></div>`,s=C(),t=m("button"),t.textContent="Close",d(e,"class","alert alert-success"),d(t,"type","button"),d(t,"class","btn btn-secondary btn-block")},m(o,c){_(o,e,c),_(o,s,c),_(o,t,c),l||(n=E(t,"click",r[6]),l=!0)},p:h,i:h,o:h,d(o){o&&b(e),o&&b(s),o&&b(t),l=!1,n()}}}function L(r){let e,s,t;return{c(){e=y("to "),s=m("strong"),t=y(r[3]),d(s,"class","txt-nowrap")},m(l,n){_(l,e,n),_(l,s,n),k(s,t)},p(l,n){n&8&&G(t,l[3])},d(l){l&&b(e),l&&b(s)}}}function O(r){let e,s,t,l,n,o,c,a;return{c(){e=m("label"),s=y("Password"),l=C(),n=m("input"),d(e,"for",t=r[8]),d(n,"type","password"),d(n,"id",o=r[8]),n.required=!0,n.autofocus=!0},m(i,u){_(i,e,u),k(e,s),_(i,l,u),_(i,n,u),T(n,r[0]),n.focus(),c||(a=E(n,"input",r[7]),c=!0)},p(i,u){u&256&&t!==(t=i[8])&&d(e,"for",t),u&256&&o!==(o=i[8])&&d(n,"id",o),u&1&&n.value!==i[0]&&T(n,i[0])},d(i){i&&b(e),i&&b(l),i&&b(n),c=!1,a()}}}function Q(r){let e,s,t,l;const n=[K,I],o=[];function c(a,i){return a[2]?0:1}return e=c(r),s=o[e]=n[e](r),{c(){s.c(),t=Y()},m(a,i){o[e].m(a,i),_(a,t,i),l=!0},p(a,i){let u=e;e=c(a),e===u?o[e].p(a,i):(j(),v(o[u],1,1,()=>{o[u]=null}),A(),s=o[e],s?s.p(a,i):(s=o[e]=n[e](a),s.c()),$(s,1),s.m(t.parentNode,t))},i(a){l||($(s),l=!0)},o(a){v(s),l=!1},d(a){o[e].d(a),a&&b(t)}}}function V(r){let e,s;return e=new U({props:{nobranding:!0,$$slots:{default:[Q]},$$scope:{ctx:r}}}),{c(){S(e.$$.fragment)},m(t,l){z(e,t,l),s=!0},p(t,[l]){const n={};l&527&&(n.$$scope={dirty:l,ctx:t}),e.$set(n)},i(t){s||($(e.$$.fragment,t),s=!0)},o(t){v(e.$$.fragment,t),s=!1},d(t){J(e,t)}}}function X(r,e,s){let t,{params:l}=e,n="",o=!1,c=!1;async function a(){if(!o){s(1,o=!0);try{await F.users.confirmEmailChange(l==null?void 0:l.token,n),s(2,c=!0)}catch(g){F.errorResponseHandler(g)}s(1,o=!1)}}const i=()=>window.close();function u(){n=this.value,s(0,n)}return r.$$set=g=>{"params"in g&&s(5,l=g.params)},r.$$.update=()=>{r.$$.dirty&32&&s(3,t=W.getJWTPayload(l==null?void 0:l.token).newEmail||"")},[n,o,c,t,a,l,i,u]}class x extends M{constructor(e){super(),N(this,e,X,V,R,{params:5})}}export{x as default};
@@ -1,4 +1,4 @@
import{S as W,i as Y,s as j,F as A,c as H,m as N,t as P,a as q,d as S,C as B,E as D,g as _,k as G,n as I,o as m,p as z,q as E,e as b,w as y,b as C,f as c,r as J,h as w,u as h,v as K,y as F,x as O,z as R}from"./index.38300823.js";function Q(i){let e,l,t,n,s,o,p,a,r,u,v,g,k,L,d=i[4]&&M(i);return o=new E({props:{class:"form-field required",name:"password",$$slots:{default:[X,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),a=new E({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[Z,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),{c(){e=b("form"),l=b("div"),t=b("h4"),n=y(`Reset your user password
`),d&&d.c(),s=C(),H(o.$$.fragment),p=C(),H(a.$$.fragment),r=C(),u=b("button"),v=b("span"),v.textContent="Set new password",c(t,"class","m-b-xs"),c(l,"class","content txt-center m-b-sm"),c(v,"class","txt"),c(u,"type","submit"),c(u,"class","btn btn-lg btn-block"),u.disabled=i[2],J(u,"btn-loading",i[2])},m(f,$){_(f,e,$),w(e,l),w(l,t),w(t,n),d&&d.m(t,null),w(e,s),N(o,e,null),w(e,p),N(a,e,null),w(e,r),w(e,u),w(u,v),g=!0,k||(L=h(e,"submit",K(i[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=M(f),d.c(),d.m(t,null)):d&&(d.d(1),d=null);const T={};$&3073&&(T.$$scope={dirty:$,ctx:f}),o.$set(T);const U={};$&3074&&(U.$$scope={dirty:$,ctx:f}),a.$set(U),(!g||$&4)&&(u.disabled=f[2]),$&4&&J(u,"btn-loading",f[2])},i(f){g||(P(o.$$.fragment,f),P(a.$$.fragment,f),g=!0)},o(f){q(o.$$.fragment,f),q(a.$$.fragment,f),g=!1},d(f){f&&m(e),d&&d.d(),S(o),S(a),k=!1,L()}}}function V(i){let e,l,t,n,s;return{c(){e=b("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
import{S as W,i as Y,s as j,F as A,c as H,m as N,t as P,a as q,d as S,C as B,E as D,g as _,k as G,n as I,o as m,p as E,q as J,e as b,w as y,b as C,f as c,r as M,h as w,u as h,v as K,y as F,x as O,z as R}from"./index.02c04c04.js";function Q(i){let e,l,t,n,s,o,p,a,r,u,v,g,k,L,d=i[4]&&U(i);return o=new J({props:{class:"form-field required",name:"password",$$slots:{default:[X,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),a=new J({props:{class:"form-field required",name:"passwordConfirm",$$slots:{default:[Z,({uniqueId:f})=>({10:f}),({uniqueId:f})=>f?1024:0]},$$scope:{ctx:i}}}),{c(){e=b("form"),l=b("div"),t=b("h4"),n=y(`Reset your user password
`),d&&d.c(),s=C(),H(o.$$.fragment),p=C(),H(a.$$.fragment),r=C(),u=b("button"),v=b("span"),v.textContent="Set new password",c(t,"class","m-b-xs"),c(l,"class","content txt-center m-b-sm"),c(v,"class","txt"),c(u,"type","submit"),c(u,"class","btn btn-lg btn-block"),u.disabled=i[2],M(u,"btn-loading",i[2])},m(f,$){_(f,e,$),w(e,l),w(l,t),w(t,n),d&&d.m(t,null),w(e,s),N(o,e,null),w(e,p),N(a,e,null),w(e,r),w(e,u),w(u,v),g=!0,k||(L=h(e,"submit",K(i[5])),k=!0)},p(f,$){f[4]?d?d.p(f,$):(d=U(f),d.c(),d.m(t,null)):d&&(d.d(1),d=null);const T={};$&3073&&(T.$$scope={dirty:$,ctx:f}),o.$set(T);const z={};$&3074&&(z.$$scope={dirty:$,ctx:f}),a.$set(z),(!g||$&4)&&(u.disabled=f[2]),$&4&&M(u,"btn-loading",f[2])},i(f){g||(P(o.$$.fragment,f),P(a.$$.fragment,f),g=!0)},o(f){q(o.$$.fragment,f),q(a.$$.fragment,f),g=!1},d(f){f&&m(e),d&&d.d(),S(o),S(a),k=!1,L()}}}function V(i){let e,l,t,n,s;return{c(){e=b("div"),e.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Password changed</p>
<p>You can now sign in with your new password.</p></div>`,l=C(),t=b("button"),t.textContent="Close",c(e,"class","alert alert-success"),c(t,"type","button"),c(t,"class","btn btn-secondary btn-block")},m(o,p){_(o,e,p),_(o,l,p),_(o,t,p),n||(s=h(t,"click",i[7]),n=!0)},p:F,i:F,o:F,d(o){o&&m(e),o&&m(l),o&&m(t),n=!1,s()}}}function M(i){let e,l,t;return{c(){e=y("for "),l=b("strong"),t=y(i[4])},m(n,s){_(n,e,s),_(n,l,s),w(l,t)},p(n,s){s&16&&O(t,n[4])},d(n){n&&m(e),n&&m(l)}}}function X(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0,s.autofocus=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[0]),s.focus(),p||(a=h(s,"input",i[8]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&1&&s.value!==r[0]&&R(s,r[0])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function Z(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password confirm"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[1]),p||(a=h(s,"input",i[9]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&2&&s.value!==r[1]&&R(s,r[1])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function x(i){let e,l,t,n;const s=[V,Q],o=[];function p(a,r){return a[3]?0:1}return e=p(i),l=o[e]=s[e](i),{c(){l.c(),t=D()},m(a,r){o[e].m(a,r),_(a,t,r),n=!0},p(a,r){let u=e;e=p(a),e===u?o[e].p(a,r):(G(),q(o[u],1,1,()=>{o[u]=null}),I(),l=o[e],l?l.p(a,r):(l=o[e]=s[e](a),l.c()),P(l,1),l.m(t.parentNode,t))},i(a){n||(P(l),n=!0)},o(a){q(l),n=!1},d(a){o[e].d(a),a&&m(t)}}}function ee(i){let e,l;return e=new A({props:{nobranding:!0,$$slots:{default:[x]},$$scope:{ctx:i}}}),{c(){H(e.$$.fragment)},m(t,n){N(e,t,n),l=!0},p(t,[n]){const s={};n&2079&&(s.$$scope={dirty:n,ctx:t}),e.$set(s)},i(t){l||(P(e.$$.fragment,t),l=!0)},o(t){q(e.$$.fragment,t),l=!1},d(t){S(e,t)}}}function te(i,e,l){let t,{params:n}=e,s="",o="",p=!1,a=!1;async function r(){if(!p){l(2,p=!0);try{await z.Users.confirmPasswordReset(n==null?void 0:n.token,s,o),l(3,a=!0)}catch(k){z.errorResponseHandler(k)}l(2,p=!1)}}const u=()=>window.close();function v(){s=this.value,l(0,s)}function g(){o=this.value,l(1,o)}return i.$$set=k=>{"params"in k&&l(6,n=k.params)},i.$$.update=()=>{i.$$.dirty&64&&l(4,t=B.getJWTPayload(n==null?void 0:n.token).email||"")},[s,o,p,a,t,r,n,u,v,g]}class le extends W{constructor(e){super(),Y(this,e,te,ee,j,{params:6})}}export{le as default};
<p>You can now sign in with your new password.</p></div>`,l=C(),t=b("button"),t.textContent="Close",c(e,"class","alert alert-success"),c(t,"type","button"),c(t,"class","btn btn-secondary btn-block")},m(o,p){_(o,e,p),_(o,l,p),_(o,t,p),n||(s=h(t,"click",i[7]),n=!0)},p:F,i:F,o:F,d(o){o&&m(e),o&&m(l),o&&m(t),n=!1,s()}}}function U(i){let e,l,t;return{c(){e=y("for "),l=b("strong"),t=y(i[4])},m(n,s){_(n,e,s),_(n,l,s),w(l,t)},p(n,s){s&16&&O(t,n[4])},d(n){n&&m(e),n&&m(l)}}}function X(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0,s.autofocus=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[0]),s.focus(),p||(a=h(s,"input",i[8]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&1&&s.value!==r[0]&&R(s,r[0])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function Z(i){let e,l,t,n,s,o,p,a;return{c(){e=b("label"),l=y("New password confirm"),n=C(),s=b("input"),c(e,"for",t=i[10]),c(s,"type","password"),c(s,"id",o=i[10]),s.required=!0},m(r,u){_(r,e,u),w(e,l),_(r,n,u),_(r,s,u),R(s,i[1]),p||(a=h(s,"input",i[9]),p=!0)},p(r,u){u&1024&&t!==(t=r[10])&&c(e,"for",t),u&1024&&o!==(o=r[10])&&c(s,"id",o),u&2&&s.value!==r[1]&&R(s,r[1])},d(r){r&&m(e),r&&m(n),r&&m(s),p=!1,a()}}}function x(i){let e,l,t,n;const s=[V,Q],o=[];function p(a,r){return a[3]?0:1}return e=p(i),l=o[e]=s[e](i),{c(){l.c(),t=D()},m(a,r){o[e].m(a,r),_(a,t,r),n=!0},p(a,r){let u=e;e=p(a),e===u?o[e].p(a,r):(G(),q(o[u],1,1,()=>{o[u]=null}),I(),l=o[e],l?l.p(a,r):(l=o[e]=s[e](a),l.c()),P(l,1),l.m(t.parentNode,t))},i(a){n||(P(l),n=!0)},o(a){q(l),n=!1},d(a){o[e].d(a),a&&m(t)}}}function ee(i){let e,l;return e=new A({props:{nobranding:!0,$$slots:{default:[x]},$$scope:{ctx:i}}}),{c(){H(e.$$.fragment)},m(t,n){N(e,t,n),l=!0},p(t,[n]){const s={};n&2079&&(s.$$scope={dirty:n,ctx:t}),e.$set(s)},i(t){l||(P(e.$$.fragment,t),l=!0)},o(t){q(e.$$.fragment,t),l=!1},d(t){S(e,t)}}}function te(i,e,l){let t,{params:n}=e,s="",o="",p=!1,a=!1;async function r(){if(!p){l(2,p=!0);try{await E.users.confirmPasswordReset(n==null?void 0:n.token,s,o),l(3,a=!0)}catch(k){E.errorResponseHandler(k)}l(2,p=!1)}}const u=()=>window.close();function v(){s=this.value,l(0,s)}function g(){o=this.value,l(1,o)}return i.$$set=k=>{"params"in k&&l(6,n=k.params)},i.$$.update=()=>{i.$$.dirty&64&&l(4,t=B.getJWTPayload(n==null?void 0:n.token).email||"")},[s,o,p,a,t,r,n,u,v,g]}class le extends W{constructor(e){super(),Y(this,e,te,ee,j,{params:6})}}export{le as default};
@@ -1,3 +1,3 @@
import{S as k,i as v,s as y,F as w,c as x,m as C,t as g,a as $,d as L,p as H,E as M,g as r,o as a,e as u,b as m,f,u as _,y as p}from"./index.38300823.js";function P(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-error-warning-line"></i></div>
import{S as k,i as v,s as y,F as w,c as x,m as C,t as g,a as $,d as L,p as H,E as M,g as r,o as a,e as u,b as m,f,u as _,y as p}from"./index.02c04c04.js";function P(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-error-warning-line"></i></div>
<div class="content txt-bold"><p>Invalid or expired verification token.</p></div>`,s=m(),e=u("button"),e.textContent="Close",f(t,"class","alert alert-danger"),f(e,"type","button"),f(e,"class","btn btn-secondary btn-block")},m(l,c){r(l,t,c),r(l,s,c),r(l,e,c),n||(i=_(e,"click",o[4]),n=!0)},p,d(l){l&&a(t),l&&a(s),l&&a(e),n=!1,i()}}}function S(o){let t,s,e,n,i;return{c(){t=u("div"),t.innerHTML=`<div class="icon"><i class="ri-checkbox-circle-line"></i></div>
<div class="content txt-bold"><p>Successfully verified email address.</p></div>`,s=m(),e=u("button"),e.textContent="Close",f(t,"class","alert alert-success"),f(e,"type","button"),f(e,"class","btn btn-secondary btn-block")},m(l,c){r(l,t,c),r(l,s,c),r(l,e,c),n||(i=_(e,"click",o[3]),n=!0)},p,d(l){l&&a(t),l&&a(s),l&&a(e),n=!1,i()}}}function T(o){let t;return{c(){t=u("div"),t.innerHTML='<div class="loader loader-lg"><em>Please wait...</em></div>',f(t,"class","txt-center")},m(s,e){r(s,t,e)},p,d(s){s&&a(t)}}}function F(o){let t;function s(i,l){return i[1]?T:i[0]?S:P}let e=s(o),n=e(o);return{c(){n.c(),t=M()},m(i,l){n.m(i,l),r(i,t,l)},p(i,l){e===(e=s(i))&&n?n.p(i,l):(n.d(1),n=e(i),n&&(n.c(),n.m(t.parentNode,t)))},d(i){n.d(i),i&&a(t)}}}function U(o){let t,s;return t=new w({props:{nobranding:!0,$$slots:{default:[F]},$$scope:{ctx:o}}}),{c(){x(t.$$.fragment)},m(e,n){C(t,e,n),s=!0},p(e,[n]){const i={};n&67&&(i.$$scope={dirty:n,ctx:e}),t.$set(i)},i(e){s||(g(t.$$.fragment,e),s=!0)},o(e){$(t.$$.fragment,e),s=!1},d(e){L(t,e)}}}function V(o,t,s){let{params:e}=t,n=!1,i=!1;l();async function l(){s(1,i=!0);try{await H.Users.confirmVerification(e==null?void 0:e.token),s(0,n=!0)}catch(d){console.warn(d),s(0,n=!1)}s(1,i=!1)}const c=()=>window.close(),b=()=>window.close();return o.$$set=d=>{"params"in d&&s(2,e=d.params)},[n,i,e,c,b]}class E extends k{constructor(t){super(),v(this,t,V,U,y,{params:2})}}export{E as default};
<div class="content txt-bold"><p>Successfully verified email address.</p></div>`,s=m(),e=u("button"),e.textContent="Close",f(t,"class","alert alert-success"),f(e,"type","button"),f(e,"class","btn btn-secondary btn-block")},m(l,c){r(l,t,c),r(l,s,c),r(l,e,c),n||(i=_(e,"click",o[3]),n=!0)},p,d(l){l&&a(t),l&&a(s),l&&a(e),n=!1,i()}}}function T(o){let t;return{c(){t=u("div"),t.innerHTML='<div class="loader loader-lg"><em>Please wait...</em></div>',f(t,"class","txt-center")},m(s,e){r(s,t,e)},p,d(s){s&&a(t)}}}function F(o){let t;function s(i,l){return i[1]?T:i[0]?S:P}let e=s(o),n=e(o);return{c(){n.c(),t=M()},m(i,l){n.m(i,l),r(i,t,l)},p(i,l){e===(e=s(i))&&n?n.p(i,l):(n.d(1),n=e(i),n&&(n.c(),n.m(t.parentNode,t)))},d(i){n.d(i),i&&a(t)}}}function V(o){let t,s;return t=new w({props:{nobranding:!0,$$slots:{default:[F]},$$scope:{ctx:o}}}),{c(){x(t.$$.fragment)},m(e,n){C(t,e,n),s=!0},p(e,[n]){const i={};n&67&&(i.$$scope={dirty:n,ctx:e}),t.$set(i)},i(e){s||(g(t.$$.fragment,e),s=!0)},o(e){$(t.$$.fragment,e),s=!1},d(e){L(t,e)}}}function q(o,t,s){let{params:e}=t,n=!1,i=!1;l();async function l(){s(1,i=!0);try{await H.users.confirmVerification(e==null?void 0:e.token),s(0,n=!0)}catch(d){console.warn(d),s(0,n=!1)}s(1,i=!1)}const c=()=>window.close(),b=()=>window.close();return o.$$set=d=>{"params"in d&&s(2,e=d.params)},[n,i,e,c,b]}class I extends k{constructor(t){super(),v(this,t,q,V,y,{params:2})}}export{I as default};
+638
View File
File diff suppressed because one or more lines are too long
-382
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+12 -8
View File
@@ -3,15 +3,19 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='"
/>
<title>PocketBase</title>
<link rel="apple-touch-icon" sizes="180x180" href="/_/images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/_/images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/_/images/favicon/favicon-16x16.png">
<link rel="manifest" href="/_/images/favicon/site.webmanifest">
<link rel="mask-icon" href="/_/images/favicon/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="/_/images/favicon/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="./images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./images/favicon/favicon-16x16.png">
<link rel="manifest" href="./images/favicon/site.webmanifest">
<link rel="mask-icon" href="./images/favicon/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="./images/favicon/favicon.ico">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-config" content="./images/favicon/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
@@ -20,8 +24,8 @@
window.Prism = window.Prism || {};
window.Prism.manual = true;
</script>
<script type="module" crossorigin src="/_/assets/index.38300823.js"></script>
<link rel="stylesheet" href="/_/assets/index.615ff839.css">
<script type="module" crossorigin src="./assets/index.02c04c04.js"></script>
<link rel="stylesheet" href="./assets/index.3b402ee9.css">
</head>
<body>
<div id="app"></div>
-6
View File
@@ -10,11 +10,5 @@ import (
//go:embed all:dist
var distDir embed.FS
//go:embed dist/index.html
var indexHTML embed.FS
// DistDirFS contains the embedded dist directory files (without the "dist" prefix)
var DistDirFS = echo.MustSubFS(distDir, "dist")
// DistIndexHTML contains the embedded dist/index.html file (without the "dist" prefix)
var DistIndexHTML = echo.MustSubFS(indexHTML, "dist")
+11 -7
View File
@@ -3,15 +3,19 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' http://127.0.0.1:* data:; connect-src 'self' http://127.0.0.1:*; script-src 'self' 'sha256-GRUzBA7PzKYug7pqxv5rJaec5bwDCw1Vo6/IXwvD3Tc='"
/>
<title>PocketBase</title>
<link rel="apple-touch-icon" sizes="180x180" href="/images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/images/favicon/favicon-16x16.png">
<link rel="manifest" href="/images/favicon/site.webmanifest">
<link rel="mask-icon" href="/images/favicon/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="/images/favicon/favicon.ico">
<link rel="apple-touch-icon" sizes="180x180" href="./images/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="./images/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="./images/favicon/favicon-16x16.png">
<link rel="manifest" href="./images/favicon/site.webmanifest">
<link rel="mask-icon" href="./images/favicon/safari-pinned-tab.svg" color="#000000">
<link rel="shortcut icon" href="./images/favicon/favicon.ico">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-config" content="./images/favicon/browserconfig.xml">
<meta name="theme-color" content="#ffffff">
@@ -23,6 +27,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script type="module" src="./src/main.js"></script>
</body>
</html>
+300 -275
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -23,7 +23,7 @@
"chart.js": "^3.7.1",
"chartjs-adapter-luxon": "^1.1.0",
"luxon": "^2.3.2",
"pocketbase": "^0.2.1",
"pocketbase": "^0.4.0",
"prismjs": "^1.28.0",
"sass": "^1.45.0",
"svelte": "^3.44.0",
+3 -3
View File
@@ -48,7 +48,7 @@
}
try {
const settings = await ApiClient.Settings.getAll({
const settings = await ApiClient.settings.getAll({
$cancelKey: "loadAppName",
});
$appName = settings?.meta?.appName || "";
@@ -143,9 +143,9 @@
<div class="app-body">
<Router {routes} on:routeLoading={handleRouteLoading} on:conditionsFailed={handleRouteFailure} />
<Toasts />
</div>
</div>
<Toasts />
<Confirmation />
+4 -1
View File
@@ -61,6 +61,9 @@ function refreshTooltip(node, data) {
if (data.class) {
tooltip.classList.add(data.class);
}
if (data.position) {
tooltip.classList.add(data.position);
}
// reset tooltip position
tooltip.style.top = "0px";
@@ -138,7 +141,7 @@ function showTooltip(node, data) {
getTooltip().classList.add("active");
refreshTooltip(node, data);
}, (!isNaN(data.delay) ? data.delay : 250));
}, (!isNaN(data.delay) ? data.delay : 200));
}
export default function tooltip(node, tooltipData) {
+1 -1
View File
@@ -19,7 +19,7 @@
return;
}
if (ApiClient.AuthStore.isValid) {
if (ApiClient.authStore.isValid) {
replace("/collections");
} else {
ApiClient.logout();
@@ -71,9 +71,9 @@
let request;
if (admin.isNew) {
request = ApiClient.Admins.create(data);
request = ApiClient.admins.create(data);
} else {
request = ApiClient.Admins.update(admin.id, data);
request = ApiClient.admins.update(admin.id, data);
}
request
@@ -83,8 +83,8 @@
addSuccessToast(admin.isNew ? "Successfully created admin." : "Successfully updated admin.");
dispatch("save", result);
if (ApiClient.AuthStore.model?.id === result.id) {
ApiClient.AuthStore.save(ApiClient.AuthStore.token, result);
if (ApiClient.authStore.model?.id === result.id) {
ApiClient.authStore.save(ApiClient.authStore.token, result);
}
})
.catch((err) => {
@@ -101,7 +101,8 @@
}
confirm(`Do you really want to delete the selected admin?`, () => {
return ApiClient.Admins.delete(admin.id)
return ApiClient.admins
.delete(admin.id)
.then(() => {
confirmClose = false;
hide();
@@ -190,7 +191,7 @@
{#if admin.isNew || changePasswordToggle}
<div class="col-12">
<div class="grid" transition:slide={{ duration: 150 }}>
<div class="grid" transition:slide|local={{ duration: 150 }}>
<div class="col-sm-6">
<Field class="form-field required" name="password" let:uniqueId>
<label for={uniqueId}>
@@ -22,7 +22,7 @@
isLoading = true;
try {
await ApiClient.Admins.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
await ApiClient.admins.confirmPasswordReset(params?.token, newPassword, newPasswordConfirm);
addSuccessToast("Successfully set a new admin password.");
replace("/");
} catch (err) {
@@ -1,16 +1,14 @@
<script>
import { link } from "svelte-spa-router";
import { replace } from "svelte-spa-router";
import { link, replace, querystring } from "svelte-spa-router";
import FullPage from "@/components/base/FullPage.svelte";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import Field from "@/components/base/Field.svelte";
import { addErrorToast } from "@/stores/toasts";
const queryParams = CommonHelper.getQueryParams(window.location?.href);
const queryParams = new URLSearchParams($querystring);
let email = queryParams.demoEmail || "";
let password = queryParams.demoPassword || "";
let email = queryParams.get("demoEmail") || "";
let password = queryParams.get("demoPassword") || "";
let isLoading = false;
function login() {
@@ -20,7 +18,7 @@
isLoading = true;
return ApiClient.Admins.authViaEmail(email, password)
return ApiClient.admins.authViaEmail(email, password)
.then(() => {
replace("/");
})
@@ -16,7 +16,7 @@
isLoading = true;
try {
await ApiClient.Admins.requestPasswordReset(email);
await ApiClient.admins.requestPasswordReset(email);
success = true;
} catch (err) {
ApiClient.errorResponseHandler(err);
+15 -14
View File
@@ -1,8 +1,10 @@
<script>
import { replace, querystring } from "svelte-spa-router";
import ApiClient from "@/utils/ApiClient";
import CommonHelper from "@/utils/CommonHelper";
import { pageTitle } from "@/stores/app";
import { admin as loggedAdmin } from "@/stores/admin";
import PageWrapper from "@/components/base/PageWrapper.svelte";
import Searchbar from "@/components/base/Searchbar.svelte";
import RefreshButton from "@/components/base/RefreshButton.svelte";
import SortHeader from "@/components/base/SortHeader.svelte";
@@ -11,22 +13,20 @@
import SettingsSidebar from "@/components/settings/SettingsSidebar.svelte";
import AdminUpsertPanel from "@/components/admins/AdminUpsertPanel.svelte";
const queryParams = CommonHelper.getQueryParams(window.location?.href);
$pageTitle = "Admins";
const queryParams = new URLSearchParams($querystring);
let adminUpsertPanel;
let admins = [];
let isLoading = false;
let filter = queryParams.filter || "";
let sort = queryParams.sort || "-created";
let filter = queryParams.get("filter") || "";
let sort = queryParams.get("sort") || "-created";
$: if (sort !== -1 && filter !== -1) {
// keep listing params in sync
CommonHelper.replaceClientQueryParams({
filter: filter,
sort: sort,
});
const query = new URLSearchParams({ filter, sort }).toString();
replace("/settings/admins?" + query);
loadAdmins();
}
@@ -36,10 +36,11 @@
admins = []; // reset
return ApiClient.Admins.getFullList(100, {
sort: sort || "-created",
filter: filter,
})
return ApiClient.admins
.getFullList(100, {
sort: sort || "-created",
filter: filter,
})
.then((result) => {
admins = result;
isLoading = false;
@@ -61,7 +62,7 @@
<SettingsSidebar />
<main class="page-wrapper">
<PageWrapper>
<header class="page-header">
<nav class="breadcrumbs">
<div class="breadcrumb-item">Settings</div>
@@ -198,6 +199,6 @@
{#if admins.length}
<small class="block txt-hint txt-right m-t-sm">Showing {admins.length} of {admins.length}</small>
{/if}
</main>
</PageWrapper>
<AdminUpsertPanel bind:this={adminUpsertPanel} on:save={() => loadAdmins()} on:delete={() => loadAdmins()} />
+8 -1
View File
@@ -1,8 +1,12 @@
<script>
import Prism from "prismjs";
import "prismjs/plugins/normalize-whitespace/prism-normalize-whitespace.js";
import "prismjs/components/prism-dart.js";
import "@/scss/prism_light.scss";
let classes = "";
export { classes as class }; // export reserved keyword
export let content = "";
export let language = "javascript"; // javascript, html
@@ -27,7 +31,7 @@
}
</script>
<div class="code-wrapper prism-light">
<div class="code-wrapper prism-light {classes}">
<code>{@html formattedContent}</code>
</div>
@@ -42,6 +46,9 @@
.code-wrapper {
display: block;
width: 100%;
max-height: 100%;
overflow: auto; /* fallback */
overflow: overlay;
}
.prism-light code {
color: var(--txtPrimaryColor);
+1 -1
View File
@@ -39,7 +39,7 @@
{#each fieldErrors as error}
<div class="help-block help-block-error">
{#if typeof error === "object"}
{error?.message || error?.code || defaultError}
<pre>{error?.message || error?.code || defaultError}</pre>
{:else}
{error || defaultError}
{/if}

Some files were not shown because too many files have changed in this diff Show More