From f5858c75b884c4e58fd382fc8d073cfa9fe15be9 Mon Sep 17 00:00:00 2001 From: Dolgirev Leonid Date: Thu, 8 Oct 2026 15:06:31 +0300 Subject: [PATCH] fix: restore GitHub Pages static mirror without auth redirect Publish the full book on dlgrv.github.io again instead of redirecting to book.dlgrv.com. Gate interactive/OAuth to book.dlgrv.com only, allow OAuth user create, vendor PocketBase JS, and bump the Go library to v0.40.4. --- .github/workflows/pages.yml | 65 +++------ Makefile | 7 +- api/go.mod | 52 +++---- api/go.sum | 138 +++++++++--------- api/internal/app/bind.go | 9 +- .../migrations/1760000001_htlb_collections.go | 10 +- api/internal/migrations/migrations_test.go | 14 +- api/internal/testutil/app.go | 2 +- deploy/THIRD_PARTY.md | 2 +- deploy/nginx/api.dlgrv.com.conf | 9 +- docs/pipeline/deploy-book-api.md | 10 +- forge/v2.css | 11 ++ site/ar/index.html | 11 ++ site/assets/interactive.js | 101 +++++++++++-- site/assets/pocketbase.umd.js | 2 + site/assets/v2.css | 11 ++ site/en/index.html | 11 ++ site/es/index.html | 11 ++ site/id/index.html | 11 ++ site/login/index.html | 20 ++- site/pt/index.html | 11 ++ site/ru/index.html | 11 ++ site/zh/index.html | 11 ++ 23 files changed, 370 insertions(+), 170 deletions(-) create mode 100644 site/assets/pocketbase.umd.js diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index e3b292cb2..3c8fa01cf 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -1,5 +1,6 @@ name: pages -# GitHub Pages is a redirect stub to https://book.dlgrv.com (canonical book host). +# Deploy static book to GitHub Pages after workflow "test" succeeds on main. +# Auth / interactive sync stays on book.dlgrv.com only (see interactive.js). on: workflow_run: @@ -24,51 +25,25 @@ jobs: (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'main') runs-on: ubuntu-latest + env: + PYTHONPATH: ${{ github.workspace }} steps: - - name: Stage redirect stub - run: | - mkdir -p .publish - cat > .publish/index.html <<'HTML' - - - - - - - Moved to book.dlgrv.com - - - -

This book now lives at book.dlgrv.com.

- - - HTML - # Also redirect common locale paths via meta pages - for lang in en ru zh es pt ar id; do - mkdir -p ".publish/$lang" - cat > ".publish/$lang/index.html" < - - - -

Continue

- HTML - done - mkdir -p .publish/login - cat > .publish/login/index.html <<'HTML' - - - - -

Continue

- HTML - - uses: actions/upload-pages-artifact@v3 + - uses: actions/checkout@v4 + with: + # workflow_run checkout defaults to default branch tip; pin to the + # commit that passed tests when triggered by workflow_run. + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install deps + run: pip install -r requirements-dev.txt + - name: Build site pages + run: python3 forge/site/build_pages.py + - name: Stage Pages artifact + run: python3 forge/site/pages_artifact.py + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 with: path: .publish diff --git a/Makefile b/Makefile index d5d33ee71..8a6349258 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,8 @@ # Raw `python3 translate/…` needs PYTHONPATH=. (or use these targets). export PYTHONPATH := $(CURDIR) +# PocketBase v0.40+ needs Go ≥1.27; let the module toolchain download it. +export GOTOOLCHAIN := auto PY = .venv/bin/python3 RUFF = .venv/bin/ruff @@ -11,7 +13,7 @@ YAMLLINT = .venv/bin/yamllint .PHONY: help sync-upstream digest assemble verify verify-all wave repair status lint format test test-integration ci og og-html update-readme hooks check-commit-msg pages-artifact serve web-build quality style lt check-content check-links ebook-deps ebook-test ebook-epub ebook-pdf ebooks api-dev api-test api-lint api-format api-build api-smoke api-vuln api-cover -GOLANGCI_LINT_VERSION := v2.1.6 +GOLANGCI_LINT_VERSION := v2.14.0 GOLANGCI_LINT := github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) API_DIR := api @@ -81,7 +83,8 @@ api-cover: ## Go coverage report api-lint: ## golangci-lint + go mod tidy -diff cd $(API_DIR) && go mod tidy -diff - cd $(API_DIR) && go run $(GOLANGCI_LINT) run ./... + # Rebuild golangci-lint with Go ≥ api/go.mod (host may be older). + cd $(API_DIR) && GOTOOLCHAIN=go1.27.0 go run $(GOLANGCI_LINT) run ./... api-format: ## gofumpt via golangci-lint formatters cd $(API_DIR) && gofmt -w $$(find . -name '*.go' -not -path './.local/*') diff --git a/api/go.mod b/api/go.mod index 6dcf4d469..d32f2570b 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,10 +1,10 @@ module github.com/dlgrv/HowToLiveBetter/api -go 1.23.12 +go 1.27 require ( - github.com/pocketbase/dbx v1.11.0 - github.com/pocketbase/pocketbase v0.28.4 + github.com/pocketbase/dbx v1.12.0 + github.com/pocketbase/pocketbase v0.40.4 ) require ( @@ -12,30 +12,32 @@ require ( github.com/disintegration/imaging v1.6.2 // indirect github.com/domodwyer/mailyak/v3 v3.6.2 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/fatih/color v1.18.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect - github.com/ganigeorgiev/fexpr v0.5.0 // indirect - github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect + github.com/fatih/color v1.19.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.15 // indirect + github.com/ganigeorgiev/fexpr v0.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-cmp v0.6.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/mattn/go-colorable v0.1.15 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pocketbase/ozzo-validation/v4 v4.3.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/spf13/cast v1.9.2 // indirect - github.com/spf13/cobra v1.9.1 // indirect - github.com/spf13/pflag v1.0.6 // indirect - golang.org/x/crypto v0.39.0 // indirect - golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 // indirect - golang.org/x/image v0.28.0 // indirect - golang.org/x/net v0.41.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - modernc.org/libc v1.65.10 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/image v0.46.0 // indirect + golang.org/x/net v0.59.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.23.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.38.0 // indirect + modernc.org/memory v1.12.0 // indirect + modernc.org/sqlite v1.57.0 // indirect ) diff --git a/api/go.sum b/api/go.sum index 2b9bf53b6..d4949bdc7 100644 --- a/api/go.sum +++ b/api/go.sum @@ -10,113 +10,121 @@ github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCO github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= -github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= -github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk= -github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= -github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= -github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= +github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= +github.com/ganigeorgiev/fexpr v0.6.0 h1:Fza3O/QMBKEudUvxV862qe6GjxM60GJjjKytdp+VQus= +github.com/ganigeorgiev/fexpr v0.6.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE= github.com/go-sql-driver/mysql v1.4.1 h1:g24URVg0OFbNUTx9qqY1IRZ9D9z3iPyi5zKhQZpNwpA= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= -github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= -github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20260902005441-ca85771921e4 h1:/6mPXfWmhv8eKck12I0YNIcIjwHtxP3YRIMKiEgTjWg= +github.com/google/pprof v0.0.0-20260902005441-ca85771921e4/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= +github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU= -github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pocketbase/pocketbase v0.28.4 h1:RmhWXDcfKrFM9/W0G0Zrlv4eKBM8/s/v4SQKytjgD20= -github.com/pocketbase/pocketbase v0.28.4/go.mod h1:jSuN93vE/oeJVOz2D2ZxcYyr2bYNmDOMCUkM+JhyJQ0= +github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= +github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= +github.com/pocketbase/ozzo-validation/v4 v4.3.0 h1:uKBDVma7bZqgR2a6AwE+k9hkuDFfiZMpBHQdZ1z3iQs= +github.com/pocketbase/ozzo-validation/v4 v4.3.0/go.mod h1:6XNjSTw/Jb2F8LOkKO3oyzIWExbrGiYoS4uVxVwz90g= +github.com/pocketbase/pocketbase v0.40.4 h1:0SvSUreR3NhUMCs9LchE59oEG53efZ3cKiMGyAGBN9U= +github.com/pocketbase/pocketbase v0.40.4/go.mod h1:2mU+80FLiY1fb13WZRg8Xx/lKg4nTjgKVJMigyRH6k0= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE= -github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476 h1:bsqhLWFR6G6xiQcb+JoGqdKdRU6WzPWmK8E0jxTjzo4= -golang.org/x/exp v0.0.0-20250606033433-dcc06ee1d476/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.28.0 h1:gdem5JW1OLS4FbkWgLO+7ZeFzYtL3xClb97GaUzYMFE= -golang.org/x/image v0.28.0/go.mod h1:GUJYXtnGKEUgggyzh+Vxt+AviiCcyiwpsl8iQ8MvwGY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= -modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= -modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc= -modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= -modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= -modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/memory v1.12.0 h1:twkmYNkGXCvtYWzoux02jtK6eovjZbdI0uHFUYp6kuU= +modernc.org/memory v1.12.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= -modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/api/internal/app/bind.go b/api/internal/app/bind.go index f2769a685..af7a40689 100644 --- a/api/internal/app/bind.go +++ b/api/internal/app/bind.go @@ -68,7 +68,7 @@ func applyOAuth(app core.App, cfg config.Config) error { if err != nil { return err } - denyAPI(users) + lockUsersExceptOAuthCreate(users) users.PasswordAuth.Enabled = false googleID := strings.TrimSpace(os.Getenv("HTLB_OAUTH_GOOGLE_CLIENT_ID")) @@ -96,11 +96,14 @@ func applyOAuth(app core.App, cfg config.Config) error { return app.Save(users) } -func denyAPI(c *core.Collection) { +// lockUsersExceptOAuthCreate keeps users CRUD locked except Create, which must +// allow OAuth signup (nil CreateRule = superuser-only → new OAuth users fail). +func lockUsersExceptOAuthCreate(c *core.Collection) { c.ListRule = nil c.ViewRule = nil - c.CreateRule = nil c.UpdateRule = nil c.DeleteRule = nil c.ManageRule = nil + oauthCreate := `@request.context = "oauth2"` + c.CreateRule = &oauthCreate } diff --git a/api/internal/migrations/1760000001_htlb_collections.go b/api/internal/migrations/1760000001_htlb_collections.go index 6cb6e7879..790c58bb6 100644 --- a/api/internal/migrations/1760000001_htlb_collections.go +++ b/api/internal/migrations/1760000001_htlb_collections.go @@ -137,7 +137,14 @@ func lockUsers(app core.App) error { if err != nil { return err } - denyAll(users) + users.ListRule = nil + users.ViewRule = nil + users.UpdateRule = nil + users.DeleteRule = nil + users.ManageRule = nil + // Empty/locked create blocks OAuth signup; allow only oauth2 context. + oauthCreate := `@request.context = "oauth2"` + users.CreateRule = &oauthCreate users.PasswordAuth.Enabled = false // OAuth providers are enabled at runtime when client credentials exist. users.OAuth2.Enabled = false @@ -145,6 +152,5 @@ func lockUsers(app core.App) error { {Name: "google"}, {Name: "github"}, } - users.ManageRule = nil return app.Save(users) } diff --git a/api/internal/migrations/migrations_test.go b/api/internal/migrations/migrations_test.go index 93191c328..c3637f2cf 100644 --- a/api/internal/migrations/migrations_test.go +++ b/api/internal/migrations/migrations_test.go @@ -9,7 +9,7 @@ import ( func TestCollectionsLocked(t *testing.T) { app := testutil.NewApp(t) for _, name := range []string{ - "guest_sessions", "guest_claims", "useful_votes", "bookmarks", "reading_positions", "users", + "guest_sessions", "guest_claims", "useful_votes", "bookmarks", "reading_positions", } { col, err := app.FindCollectionByNameOrId(name) if err != nil { @@ -19,10 +19,20 @@ func TestCollectionsLocked(t *testing.T) { t.Fatalf("%s rules not fully locked", name) } } - users, _ := app.FindCollectionByNameOrId("users") + users, err := app.FindCollectionByNameOrId("users") + if err != nil { + t.Fatal(err) + } if users.PasswordAuth.Enabled { t.Fatal("password auth should be disabled") } + if users.ListRule != nil || users.ViewRule != nil || users.UpdateRule != nil || users.DeleteRule != nil || users.ManageRule != nil { + t.Fatal("users non-create rules should be locked") + } + want := `@request.context = "oauth2"` + if users.CreateRule == nil || *users.CreateRule != want { + t.Fatalf("users CreateRule want %q, got %#v", want, users.CreateRule) + } } func TestReApplyMigrations(t *testing.T) { diff --git a/api/internal/testutil/app.go b/api/internal/testutil/app.go index dd2e87255..73e20a94a 100644 --- a/api/internal/testutil/app.go +++ b/api/internal/testutil/app.go @@ -32,7 +32,7 @@ func NewApp(t *testing.T) core.App { t.Fatalf("migrations: %v", err) } t.Cleanup(func() { - _ = app.ResetBootstrapState() + _ = app.ClearBootstrap() _ = os.RemoveAll(dir) }) return app diff --git a/deploy/THIRD_PARTY.md b/deploy/THIRD_PARTY.md index 9710b84ee..05348c866 100644 --- a/deploy/THIRD_PARTY.md +++ b/deploy/THIRD_PARTY.md @@ -3,7 +3,7 @@ ## PocketBase - **Project:** [github.com/pocketbase/pocketbase](https://github.com/pocketbase/pocketbase) -- **Version (pinned):** v0.28.4 (see `api/go.mod`) +- **Version (pinned):** v0.40.4 (see `api/go.mod`); JS SDK `pocketbase@0.28.1` in `site/assets/pocketbase.umd.js` - **License:** MIT `htlb-api` embeds PocketBase as a Go library (not a separate PocketBase binary). diff --git a/deploy/nginx/api.dlgrv.com.conf b/deploy/nginx/api.dlgrv.com.conf index 8f506478d..f74942b0b 100644 --- a/deploy/nginx/api.dlgrv.com.conf +++ b/deploy/nginx/api.dlgrv.com.conf @@ -27,14 +27,9 @@ server { client_max_body_size 2m; + # Public: PocketBase OAuth popup lands on /_/#/auth/oauth2-* after + # /api/oauth2-redirect. Admin UI still requires a superuser login. location /_/ { - allow 127.0.0.1; - allow 10.0.0.0/8; - allow 172.16.0.0/12; - allow 192.168.0.0/16; - allow 100.64.0.0/10; - deny all; - proxy_pass http://127.0.0.1:8090; proxy_http_version 1.1; proxy_set_header Host $host; diff --git a/docs/pipeline/deploy-book-api.md b/docs/pipeline/deploy-book-api.md index e0a973750..f12f8ca14 100644 --- a/docs/pipeline/deploy-book-api.md +++ b/docs/pipeline/deploy-book-api.md @@ -9,7 +9,7 @@ Ops checklist for the interactive book stack (fork dlgrv). Product decisions liv | `https://book.dlgrv.com` | Static book (nginx → `/var/www/book.dlgrv.com`) | | `https://book.dlgrv.com/login/` | Sign-in page (Google/GitHub); `?next=` returns to the book URL after OAuth | | `https://api.dlgrv.com` | `htlb-api` (Go + PocketBase) behind nginx → `127.0.0.1:8090` | -| `https://dlgrv.github.io/HowToLiveBetter/` | Redirect stub → `book.dlgrv.com` | +| `https://dlgrv.github.io/HowToLiveBetter/` | Static mirror (no auth UI; interactive sync only on `book.dlgrv.com`) | DNS (Cloudflare, grey cloud / DNS only): `book` and `api` A → dlgrv `178.104.217.93`. @@ -48,7 +48,7 @@ Env (see `deploy/htlb-api.env.example`): 1. GitHub OAuth App + Google Cloud OAuth client 2. Redirect URI: `https://api.dlgrv.com/api/oauth2-redirect` 3. Enable only Google + GitHub in PocketBase admin (`/_/`); disable password auth -4. Restrict `/_/` in nginx (IP allowlist / VPN) +4. Keep `/_/` publicly reachable in nginx — OAuth completion loads `/_/#/auth/oauth2-*` in the popup (admin actions still need a superuser) ## Secrets (GitHub Environment `production`) @@ -80,11 +80,11 @@ Live: - `https://book.dlgrv.com/en/` — static book - `https://api.dlgrv.com/api/htlb/v1/health` — API - Guest: `POST /guest/session`, `X-HTLB-Sync` on `/bookmarks` -- GitHub Pages workflow publishes redirect stub → `book.dlgrv.com` +- GitHub Pages workflow publishes the static book (auth disabled on that host) Still manual: - GitHub Environment `production` secrets: `DEPLOY_SSH_KEY`, `DEPLOY_HOST`, `DEPLOY_USER=deploy` - OAuth Google + GitHub client IDs/secrets in `/etc/htlb-api.env`, then `systemctl restart htlb-api` -- PocketBase first superuser: `ssh dlgrv` → `/opt/htlb-api/htlb-api superuser upsert EMAIL PASS` (admin UI `/_/` IP-restricted) -- Confirm github.io redirect after next Pages deploy on `main` +- PocketBase first superuser: `ssh dlgrv` → `/opt/htlb-api/htlb-api superuser upsert EMAIL PASS` (admin UI `/_/` — public shell, auth-gated) +- Confirm github.io serves the static book (no redirect) after next Pages deploy on `main` diff --git a/forge/v2.css b/forge/v2.css index 772618b73..5734472d4 100644 --- a/forge/v2.css +++ b/forge/v2.css @@ -266,6 +266,17 @@ body.login-page > *{position:relative;z-index:1} } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/ar/index.html b/site/ar/index.html index 277a2b4cc..d8b161035 100644 --- a/site/ar/index.html +++ b/site/ar/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/assets/interactive.js b/site/assets/interactive.js index 83546ca4c..0ced44ea6 100644 --- a/site/assets/interactive.js +++ b/site/assets/interactive.js @@ -1,10 +1,46 @@ /** * HTLB interactive client — guest sync + optional OAuth (Google/GitHub). * Guest: X-HTLB-Sync. User: Authorization Bearer. Never mix. + * + * Auth/sync only on book.dlgrv.com (+ local preview). GitHub Pages stays + * a static mirror without account UI. */ (function () { 'use strict'; + function interactiveHostOk() { + var h = (typeof location !== 'undefined' && location.hostname) || ''; + return ( + h === 'book.dlgrv.com' || + h === '127.0.0.1' || + h === 'localhost' || + h.endsWith('.book.dlgrv.com') + ); + } + + if (!interactiveHostOk()) { + function hideAccount() { + var acc = document.getElementById('account'); + if (acc) acc.hidden = true; + } + window.HTLBInteractive = { + init: hideAccount, + ensureGuest: function () { return Promise.resolve(null); }, + loginOAuth: function () {}, + logout: function () {}, + loginHref: function () { return '#'; }, + initLoginPage: function () { + hideAccount(); + location.replace('../en/'); + }, + api: function () { return Promise.reject(new Error('interactive disabled')); }, + }; + addEventListener('htlb:ready', hideAccount); + if (document.readyState !== 'loading') hideAccount(); + else document.addEventListener('DOMContentLoaded', hideAccount); + return; + } + var API = (typeof window !== 'undefined' && window.__HTLB_API__) || 'https://api.dlgrv.com/api/htlb/v1'; @@ -90,11 +126,34 @@ return api('/merge', { method: 'POST', body: {} }); } + function setLoginStatus(text, tone) { + var el = document.getElementById('login-status'); + if (!el) return; + if (!text) { + el.hidden = true; + el.textContent = ''; + el.removeAttribute('data-tone'); + return; + } + el.hidden = false; + el.textContent = text; + if (tone) el.setAttribute('data-tone', tone); + else el.removeAttribute('data-tone'); + } + + function setLoginBusy(busy) { + ['login-google', 'login-github'].forEach(function (id) { + var b = document.getElementById(id); + if (b) b.disabled = !!busy; + }); + } + /** OAuth via PocketBase popup (Google / GitHub only). */ function loginOAuth(provider) { if (provider !== 'google' && provider !== 'github') { throw new Error('unsupported provider'); } + var L = labels(); var w = 600; var h = 700; var left = (screen.width - w) / 2; @@ -104,15 +163,26 @@ 'htlb_oauth', 'width=' + w + ',height=' + h + ',left=' + left + ',top=' + top ); - if (!popup) throw new Error('popup blocked'); + if (!popup) { + setLoginStatus(L.loginError || 'Sign-in failed. Close the popup and try again.', 'error'); + throw new Error('popup blocked'); + } - // Load PocketBase UMD only for authWithOAuth2 - var s = document.createElement('script'); - s.src = 'https://cdn.jsdelivr.net/npm/pocketbase@0.28.4/dist/pocketbase.umd.js'; - s.onload = function () { + setLoginBusy(true); + setLoginStatus(L.loginWorking || 'Waiting for sign-in…'); + + // PocketBase UMD (vendored latest npm JS SDK; Go server version is independent — see api/go.mod) + function failOAuth(err) { + console.warn('oauth failed', err); + try { popup.close(); } catch (e) {} + setLoginBusy(false); + setLoginStatus(L.loginError || 'Sign-in failed. Close the popup and try again.', 'error'); + dispatchEvent(new CustomEvent('htlb:auth', { detail: { ok: false, error: String(err && err.message || err) } })); + } + function runOAuth() { var PBCtor = window.PocketBase; if (!PBCtor) { - popup.close(); + failOAuth(new Error('PocketBase SDK missing')); return; } var pb = new PBCtor(PB); @@ -124,14 +194,21 @@ }) .then(function () { try { popup.close(); } catch (e) {} + setLoginBusy(false); + setLoginStatus(''); dispatchEvent(new CustomEvent('htlb:auth', { detail: { ok: true } })); renderAccountMenu(); }) - .catch(function (err) { - console.warn('oauth failed', err); - try { popup.close(); } catch (e) {} - }); - }; + .catch(failOAuth); + } + if (window.PocketBase) { + runOAuth(); + return; + } + var s = document.createElement('script'); + s.src = siteBase() + 'assets/pocketbase.umd.js'; + s.onload = runOAuth; + s.onerror = function () { failOAuth(new Error('failed to load PocketBase SDK')); }; document.head.appendChild(s); } @@ -150,6 +227,8 @@ logout: t.accountLogout || 'Log out', signedIn: t.accountSignedIn || 'Signed in', hint: t.accountHint || 'Sign in so that on any of your devices you can continue reading where you left off, and keep which recommendations you marked as useful.', + loginWorking: t.loginWorking || 'Waiting for sign-in…', + loginError: t.loginError || 'Sign-in failed. Close the popup and try again.', }; } diff --git a/site/assets/pocketbase.umd.js b/site/assets/pocketbase.umd.js new file mode 100644 index 000000000..4ef37f3e3 --- /dev/null +++ b/site/assets/pocketbase.umd.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).PocketBase=t()}(this,(function(){"use strict";class ClientResponseError extends Error{constructor(e){super("ClientResponseError"),this.url="",this.status=0,this.response={},this.isAbort=!1,this.originalError=null,Object.setPrototypeOf(this,ClientResponseError.prototype),null!==e&&"object"==typeof e&&(this.originalError=e.originalError,this.url="string"==typeof e.url?e.url:"",this.status="number"==typeof e.status?e.status:0,this.isAbort=!!e.isAbort||"AbortError"===e.name||"Aborted"===e.message,null!==e.response&&"object"==typeof e.response?this.response=e.response:null!==e.data&&"object"==typeof e.data?this.response=e.data:this.response={}),this.originalError||e instanceof ClientResponseError||(this.originalError=e),this.name="ClientResponseError "+this.status,this.message=this.response?.message,this.message||(this.isAbort?this.message="The request was aborted (most likely autocancelled; you can find more info in https://github.com/pocketbase/js-sdk#auto-cancellation).":this.originalError?.cause?.message?.includes("ECONNREFUSED ::1")?this.message="Failed to connect to the PocketBase server. Try changing the SDK URL from localhost to 127.0.0.1 (https://github.com/pocketbase/js-sdk/issues/21).":this.message="Something went wrong."),this.cause=this.originalError}get data(){return this.response}toJSON(){return{...this}}}const e=/^[\u0009\u0020-\u007e\u0080-\u00ff]+$/;function cookieSerialize(t,s,i){const n=Object.assign({},i||{}),r=n.encode||defaultEncode;if(!e.test(t))throw new TypeError("argument name is invalid");const o=r(s);if(o&&!e.test(o))throw new TypeError("argument val is invalid");let a=t+"="+o;if(null!=n.maxAge){const e=n.maxAge-0;if(isNaN(e)||!isFinite(e))throw new TypeError("option maxAge is invalid");a+="; Max-Age="+Math.floor(e)}if(n.domain){if(!e.test(n.domain))throw new TypeError("option domain is invalid");a+="; Domain="+n.domain}if(n.path){if(!e.test(n.path))throw new TypeError("option path is invalid");a+="; Path="+n.path}if(n.expires){if(!function isDate(e){return"[object Date]"===Object.prototype.toString.call(e)||e instanceof Date}(n.expires)||isNaN(n.expires.valueOf()))throw new TypeError("option expires is invalid");a+="; Expires="+n.expires.toUTCString()}if(n.httpOnly&&(a+="; HttpOnly"),n.secure&&(a+="; Secure"),n.priority){switch("string"==typeof n.priority?n.priority.toLowerCase():n.priority){case"low":a+="; Priority=Low";break;case"medium":a+="; Priority=Medium";break;case"high":a+="; Priority=High";break;default:throw new TypeError("option priority is invalid")}}if(n.sameSite){switch("string"==typeof n.sameSite?n.sameSite.toLowerCase():n.sameSite){case!0:a+="; SameSite=Strict";break;case"lax":a+="; SameSite=Lax";break;case"strict":a+="; SameSite=Strict";break;case"none":a+="; SameSite=None";break;default:throw new TypeError("option sameSite is invalid")}}return a}function defaultDecode(e){return-1!==e.indexOf("%")?decodeURIComponent(e):e}function defaultEncode(e){return encodeURIComponent(e)}const t="undefined"!=typeof navigator&&"ReactNative"===navigator.product||"undefined"!=typeof global&&global.HermesInternal;let s;function getTokenPayload(e){if(e)try{const t=decodeURIComponent(s(e.split(".")[1]).split("").map((function(e){return"%"+("00"+e.charCodeAt(0).toString(16)).slice(-2)})).join(""));return JSON.parse(t)||{}}catch(e){}return{}}function isTokenExpired(e,t=0){let s=getTokenPayload(e);return!(Object.keys(s).length>0&&(!s.exp||s.exp-t>Date.now()/1e3))}s="function"!=typeof atob||t?e=>{let t=String(e).replace(/=+$/,"");if(t.length%4==1)throw new Error("'atob' failed: The string to be decoded is not correctly encoded.");for(var s,i,n=0,r=0,o="";i=t.charAt(r++);~i&&(s=n%4?64*s+i:i,n++%4)?o+=String.fromCharCode(255&s>>(-2*n&6)):0)i="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".indexOf(i);return o}:atob;const i="pb_auth";class BaseAuthStore{constructor(){this.baseToken="",this.baseModel=null,this._onChangeCallbacks=[]}get token(){return this.baseToken}get record(){return this.baseModel}get model(){return this.baseModel}get isValid(){return!isTokenExpired(this.token)}get isSuperuser(){let e=getTokenPayload(this.token);return"auth"==e.type&&("_superusers"==this.record?.collectionName||!this.record?.collectionName&&"pbc_3142635823"==e.collectionId)}get isAdmin(){return console.warn("Please replace pb.authStore.isAdmin with pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName"),this.isSuperuser}get isAuthRecord(){return console.warn("Please replace pb.authStore.isAuthRecord with !pb.authStore.isSuperuser OR simply check the value of pb.authStore.record?.collectionName"),"auth"==getTokenPayload(this.token).type&&!this.isSuperuser}save(e,t){this.baseToken=e||"",this.baseModel=t||null,this.triggerChange()}clear(){this.baseToken="",this.baseModel=null,this.triggerChange()}loadFromCookie(e,t=i){const s=function cookieParse(e,t){const s={};if("string"!=typeof e)return s;const i=Object.assign({},{}).decode||defaultDecode;let n=0;for(;n4096){r.record={id:r.record?.id,email:r.record?.email};const s=["collectionId","collectionName","verified"];for(const e in this.record)s.includes(e)&&(r.record[e]=this.record[e]);o=cookieSerialize(t,JSON.stringify(r),e)}return o}onChange(e,t=!1){return this._onChangeCallbacks.push(e),t&&e(this.token,this.record),()=>{for(let t=this._onChangeCallbacks.length-1;t>=0;t--)if(this._onChangeCallbacks[t]==e)return delete this._onChangeCallbacks[t],void this._onChangeCallbacks.splice(t,1)}}triggerChange(){for(const e of this._onChangeCallbacks)e&&e(this.token,this.record)}}class LocalAuthStore extends BaseAuthStore{constructor(e="pocketbase_auth"){super(),this.storageFallback={},this.storageKey=e,this._bindStorageEvent()}get token(){return(this._storageGet(this.storageKey)||{}).token||""}get record(){const e=this._storageGet(this.storageKey)||{};return e.record||e.model||null}get model(){return this.record}save(e,t){this._storageSet(this.storageKey,{token:e,record:t}),super.save(e,t)}clear(){this._storageRemove(this.storageKey),super.clear()}_storageGet(e){if("undefined"!=typeof window&&window?.localStorage){const t=window.localStorage.getItem(e)||"";try{return JSON.parse(t)}catch(e){return t}}return this.storageFallback[e]}_storageSet(e,t){if("undefined"!=typeof window&&window?.localStorage){let s=t;"string"!=typeof t&&(s=JSON.stringify(t)),window.localStorage.setItem(e,s)}else this.storageFallback[e]=t}_storageRemove(e){"undefined"!=typeof window&&window?.localStorage&&window.localStorage?.removeItem(e),delete this.storageFallback[e]}_bindStorageEvent(){"undefined"!=typeof window&&window?.localStorage&&window.addEventListener&&window.addEventListener("storage",(e=>{if(e.key!=this.storageKey)return;const t=this._storageGet(this.storageKey)||{};super.save(t.token||"",t.record||t.model||null)}))}}class BaseService{constructor(e){this.client=e}}class SettingsService extends BaseService{async getAll(e){return e=Object.assign({method:"GET"},e),this.client.send("/api/settings",e)}async update(e,t){return t=Object.assign({method:"PATCH",body:e},t),this.client.send("/api/settings",t)}async testS3(e="storage",t){return t=Object.assign({method:"POST",body:{filesystem:e}},t),this.client.send("/api/settings/test/s3",t).then((()=>!0))}async testEmail(e,t,s,i){return i=Object.assign({method:"POST",body:{email:t,template:s,collection:e}},i),this.client.send("/api/settings/test/email",i).then((()=>!0))}async generateAppleClientSecret(e,t,s,i,n,r){return r=Object.assign({method:"POST",body:{clientId:e,teamId:t,keyId:s,privateKey:i,duration:n}},r),this.client.send("/api/settings/apple/generate-client-secret",r)}}const n=["requestKey","$cancelKey","$autoCancel","fetch","headers","body","query","params","cache","credentials","headers","integrity","keepalive","method","mode","redirect","referrer","referrerPolicy","signal","window"];function normalizeUnknownQueryParams(e){if(e){e.query=e.query||{};for(let t in e)n.includes(t)||(e.query[t]=e[t],delete e[t])}}function serializeQueryParams(e){const t=[];for(const s in e){const i=encodeURIComponent(s),n=Array.isArray(e[s])?e[s]:[e[s]];for(let e of n)e=prepareQueryParamValue(e),null!==e&&t.push(i+"="+e)}return t.join("&")}function prepareQueryParamValue(e){return null==e?null:e instanceof Date?encodeURIComponent(e.toISOString().replace("T"," ")):"object"==typeof e?encodeURIComponent(JSON.stringify(e)):encodeURIComponent(e)}class RealtimeService extends BaseService{constructor(){super(...arguments),this.clientId="",this.eventSource=null,this.subscriptions={},this.lastSentSubscriptions=[],this.maxConnectTimeout=15e3,this.reconnectAttempts=0,this.maxReconnectAttempts=1/0,this.predefinedReconnectIntervals=[200,300,500,1e3,1200,1500,2e3],this.pendingConnects=[],this.pendingSubmits=[],this.isProcessingPendingSubmits=!1}get isConnected(){return!!this.eventSource&&!!this.clientId&&!this.pendingConnects.length}async subscribe(e,t,s){if(!e)throw new Error("topic must be set.");let i=e;if(s){normalizeUnknownQueryParams(s=Object.assign({},s));const e="options="+encodeURIComponent(JSON.stringify({query:s.query,headers:s.headers}));i+=(i.includes("?")?"&":"?")+e}const listener=function(e){const s=e;let i;try{i=JSON.parse(s?.data)}catch{}t(i||{})};return this.subscriptions[i]||(this.subscriptions[i]=[]),this.subscriptions[i].push(listener),this.isConnected?1===this.subscriptions[i].length?await this.submitSubscriptions():this.eventSource?.addEventListener(i,listener):await this.connect(),async()=>this.unsubscribeByTopicAndListener(e,listener)}async unsubscribe(e){if(e){const t=this.getSubscriptionsByTopic(e);for(let e in t)if(this.hasSubscriptionListeners(e)){for(let t of this.subscriptions[e])this.eventSource?.removeEventListener(e,t);delete this.subscriptions[e]}}else this.subscriptions={};await this.submitSubscriptions()}async unsubscribeByPrefix(e){let t=!1;for(let s in this.subscriptions)if((s+"?").startsWith(e)){t=!0;for(let e of this.subscriptions[s])this.eventSource?.removeEventListener(s,e);delete this.subscriptions[s]}t&&await this.submitSubscriptions()}async unsubscribeByTopicAndListener(e,t){const s=this.getSubscriptionsByTopic(e);for(let e in s){if(!Array.isArray(this.subscriptions[e])||!this.subscriptions[e].length)continue;let s=!1;for(let i=this.subscriptions[e].length-1;i>=0;i--)this.subscriptions[e][i]===t&&(s=!0,delete this.subscriptions[e][i],this.subscriptions[e].splice(i,1),this.eventSource?.removeEventListener(e,t));s&&(this.subscriptions[e].length||delete this.subscriptions[e])}await this.submitSubscriptions()}hasSubscriptionListeners(e){if(this.subscriptions=this.subscriptions||{},e)return!!this.subscriptions[e]?.length;for(let e in this.subscriptions)if(this.subscriptions[e]?.length)return!0;return!1}async submitSubscriptions(){return new Promise(((e,t)=>{this.pendingSubmits.push({resolve:e,reject:t}),1==this.pendingSubmits.length&&queueMicrotask((()=>this.finalizePendingSubscriptions()))}))}async finalizePendingSubscriptions(){if(this.isProcessingPendingSubmits||!this.pendingSubmits.length)return;const e=this.pendingSubmits.slice();this.pendingSubmits=[],this.isProcessingPendingSubmits=!0;try{await this.sendSubscriptions();for(let t of e)t.resolve()}catch(t){for(let s of e)t?s.reject(t):s.resolve()}finally{this.isProcessingPendingSubmits=!1,this.pendingSubmits.length>0&&await this.finalizePendingSubscriptions()}}getSubscriptionsCancelKey(){return"realtime_"+this.clientId}getSubscriptionsByTopic(e){const t={};e=e.includes("?")?e:e+"?";for(let s in this.subscriptions)(s+"?").startsWith(e)&&(t[s]=this.subscriptions[s]);return t}getNonEmptySubscriptionKeys(){const e=[];for(let t in this.subscriptions)this.subscriptions[t].length&&e.push(t);return e}hasUnsentSubscriptions(){const e=this.getNonEmptySubscriptionKeys();if(e.length!=this.lastSentSubscriptions.length)return!0;for(const t of e)if(!this.lastSentSubscriptions.includes(t))return!0;return!1}async sendSubscriptions(){if(this.clientId){if(!this.hasSubscriptionListeners())return this.disconnect();if(this.hasUnsentSubscriptions())return this.addAllSubscriptionListeners(),this.lastSentSubscriptions=this.getNonEmptySubscriptionKeys(),this.client.send("/api/realtime",{method:"POST",body:{clientId:this.clientId,subscriptions:this.lastSentSubscriptions},requestKey:this.getSubscriptionsCancelKey()}).catch((e=>{if(!e?.isAbort)throw e}))}}addAllSubscriptionListeners(){if(this.eventSource){this.removeAllSubscriptionListeners();for(let e in this.subscriptions)for(let t of this.subscriptions[e])this.eventSource.addEventListener(e,t)}}removeAllSubscriptionListeners(){if(this.eventSource)for(let e in this.subscriptions)for(let t of this.subscriptions[e])this.eventSource.removeEventListener(e,t)}async connect(){if(!(this.reconnectAttempts>0))return new Promise(((e,t)=>{this.pendingConnects.push({resolve:e,reject:t}),1==this.pendingConnects.length&&queueMicrotask((()=>this.initConnect()))}))}initConnect(){this.disconnect(!0),clearTimeout(this.connectTimeoutId),this.connectTimeoutId=setTimeout((()=>{this.connectErrorHandler(new Error("EventSource connect took too long."))}),this.maxConnectTimeout),this.eventSource=new EventSource(this.client.buildURL("/api/realtime")),this.eventSource.onerror=e=>{this.connectErrorHandler(new Error("Failed to establish realtime connection."))},this.eventSource.addEventListener("PB_CONNECT",(e=>{const t=e;this.clientId=t?.lastEventId,this.lastSentSubscriptions=[],this.submitSubscriptions().then((async()=>{let t=3;for(;this.hasUnsentSubscriptions()&&t>0;)t--,await this.submitSubscriptions();for(let e of this.pendingConnects)e.resolve();this.pendingConnects=[],this.reconnectAttempts=0,clearTimeout(this.reconnectTimeoutId),clearTimeout(this.connectTimeoutId);const s=this.getSubscriptionsByTopic("PB_CONNECT");for(let t in s)for(let i of s[t])i(e);this.hasUnsentSubscriptions()&&await this.submitSubscriptions()})).catch((e=>{this.clientId="",this.lastSentSubscriptions=[],this.connectErrorHandler(e)}))}))}connectErrorHandler(e){if(clearTimeout(this.connectTimeoutId),clearTimeout(this.reconnectTimeoutId),!this.clientId&&!this.reconnectAttempts||this.reconnectAttempts>this.maxReconnectAttempts){for(let t of this.pendingConnects)t.reject(new ClientResponseError(e));return this.pendingConnects=[],void this.disconnect()}this.disconnect(!0);const t=this.predefinedReconnectIntervals[this.reconnectAttempts]||this.predefinedReconnectIntervals[this.predefinedReconnectIntervals.length-1];this.reconnectAttempts++,this.reconnectTimeoutId=setTimeout((()=>{this.initConnect()}),t)}disconnect(e=!1){if(this.clientId&&this.onDisconnect&&this.onDisconnect(Object.keys(this.subscriptions)),clearTimeout(this.connectTimeoutId),clearTimeout(this.reconnectTimeoutId),this.removeAllSubscriptionListeners(),this.client.cancelRequest(this.getSubscriptionsCancelKey()),this.eventSource?.close(),this.eventSource=null,this.clientId="",this.lastSentSubscriptions=[],!e){this.reconnectAttempts=0;for(let e of this.pendingConnects)e.resolve();this.pendingConnects=[]}}}class CrudService extends BaseService{decode(e){return e}async getFullList(e,t){if("number"==typeof e)return this._getFullList(e,t);let s=1e3;return(t=Object.assign({},e,t)).batch&&(s=t.batch,delete t.batch),this._getFullList(s,t)}async getList(e=1,t=30,s){return(s=Object.assign({method:"GET"},s)).query=Object.assign({page:e,perPage:t},s.query),this.client.send(this.baseCrudPath,s).then((e=>(e.items=e.items?.map((e=>this.decode(e)))||[],e)))}async getFirstListItem(e,t){return(t=Object.assign({requestKey:"one_by_filter_"+this.baseCrudPath+"_"+e},t)).query=Object.assign({filter:e,skipTotal:1},t.query),this.getList(1,1,t).then((e=>{if(!e?.items?.length)throw new ClientResponseError({status:404,response:{code:404,message:"The requested resource wasn't found.",data:{}}});return e.items[0]}))}async getOne(e,t){if(!e)throw new ClientResponseError({url:this.client.buildURL(this.baseCrudPath+"/"),status:404,response:{code:404,message:"Missing required record id.",data:{}}});return t=Object.assign({method:"GET"},t),this.client.send(this.baseCrudPath+"/"+encodeURIComponent(e),t).then((e=>this.decode(e)))}async create(e,t){return t=Object.assign({method:"POST",body:e},t),this.client.send(this.baseCrudPath,t).then((e=>this.decode(e)))}async update(e,t,s){return s=Object.assign({method:"PATCH",body:t},s),this.client.send(this.baseCrudPath+"/"+encodeURIComponent(e),s).then((e=>this.decode(e)))}async delete(e,t){return t=Object.assign({method:"DELETE"},t),this.client.send(this.baseCrudPath+"/"+encodeURIComponent(e),t).then((()=>!0))}_getFullList(e=1e3,t){(t=t||{}).query=Object.assign({skipTotal:1},t.query);let s=[],request=async i=>this.getList(i,e||1e3,t).then((e=>{const t=e.items;return s=s.concat(t),t.length==e.perPage?request(i+1):s}));return request(1)}}function normalizeLegacyOptionsArgs(e,t,s,i){const n=void 0!==i;return n||void 0!==s?n?(console.warn(e),t.body=Object.assign({},t.body,s),t.query=Object.assign({},t.query,i),t):Object.assign(t,s):t}function resetAutoRefresh(e){e._resetAutoRefresh?.()}class RecordService extends CrudService{constructor(e,t){super(e),this.collectionIdOrName=t}get baseCrudPath(){return this.baseCollectionPath+"/records"}get baseCollectionPath(){return"/api/collections/"+encodeURIComponent(this.collectionIdOrName)}get isSuperusers(){return"_superusers"==this.collectionIdOrName||"_pbc_2773867675"==this.collectionIdOrName}async subscribe(e,t,s){if(!e)throw new Error("Missing topic.");if(!t)throw new Error("Missing subscription callback.");return this.client.realtime.subscribe(this.collectionIdOrName+"/"+e,t,s)}async unsubscribe(e){return e?this.client.realtime.unsubscribe(this.collectionIdOrName+"/"+e):this.client.realtime.unsubscribeByPrefix(this.collectionIdOrName)}async getFullList(e,t){if("number"==typeof e)return super.getFullList(e,t);const s=Object.assign({},e,t);return super.getFullList(s)}async getList(e=1,t=30,s){return super.getList(e,t,s)}async getFirstListItem(e,t){return super.getFirstListItem(e,t)}async getOne(e,t){return super.getOne(e,t)}async create(e,t){return super.create(e,t)}async update(e,t,s){return super.update(e,t,s).then((e=>{if(this.client.authStore.record?.id===e?.id&&(this.client.authStore.record?.collectionId===this.collectionIdOrName||this.client.authStore.record?.collectionName===this.collectionIdOrName)){let t=Object.assign({},this.client.authStore.record.expand),s=Object.assign({},this.client.authStore.record,e);t&&(s.expand=Object.assign(t,e.expand)),this.client.authStore.save(this.client.authStore.token,s)}return e}))}async delete(e,t){return super.delete(e,t).then((t=>(!t||this.client.authStore.record?.id!==e||this.client.authStore.record?.collectionId!==this.collectionIdOrName&&this.client.authStore.record?.collectionName!==this.collectionIdOrName||this.client.authStore.clear(),t)))}authResponse(e){const t=this.decode(e?.record||{});return this.client.authStore.save(e?.token,t),Object.assign({},e,{token:e?.token||"",record:t})}async listAuthMethods(e){return e=Object.assign({method:"GET",fields:"mfa,otp,password,oauth2"},e),this.client.send(this.baseCollectionPath+"/auth-methods",e)}async authWithPassword(e,t,s){let i;s=Object.assign({method:"POST",body:{identity:e,password:t}},s),this.isSuperusers&&(i=s.autoRefreshThreshold,delete s.autoRefreshThreshold,s.autoRefresh||resetAutoRefresh(this.client));let n=await this.client.send(this.baseCollectionPath+"/auth-with-password",s);return n=this.authResponse(n),i&&this.isSuperusers&&function registerAutoRefresh(e,t,s,i){resetAutoRefresh(e);const n=e.beforeSend,r=e.authStore.record,o=e.authStore.onChange(((t,s)=>{(!t||s?.id!=r?.id||(s?.collectionId||r?.collectionId)&&s?.collectionId!=r?.collectionId)&&resetAutoRefresh(e)}));e._resetAutoRefresh=function(){o(),e.beforeSend=n,delete e._resetAutoRefresh},e.beforeSend=async(r,o)=>{const a=e.authStore.token;if(o.query?.autoRefresh)return n?n(r,o):{url:r,sendOptions:o};let c=e.authStore.isValid;if(c&&isTokenExpired(e.authStore.token,t))try{await s()}catch(e){c=!1}c||await i();const l=o.headers||{};for(let t in l)if("authorization"==t.toLowerCase()&&a==l[t]&&e.authStore.token){l[t]=e.authStore.token;break}return o.headers=l,n?n(r,o):{url:r,sendOptions:o}}}(this.client,i,(()=>this.authRefresh({autoRefresh:!0})),(()=>this.authWithPassword(e,t,Object.assign({autoRefresh:!0},s)))),n}async authWithOAuth2Code(e,t,s,i,n,r,o){let a={method:"POST",body:{provider:e,code:t,codeVerifier:s,redirectURL:i,createData:n}};return a=normalizeLegacyOptionsArgs("This form of authWithOAuth2Code(provider, code, codeVerifier, redirectURL, createData?, body?, query?) is deprecated. Consider replacing it with authWithOAuth2Code(provider, code, codeVerifier, redirectURL, createData?, options?).",a,r,o),this.client.send(this.baseCollectionPath+"/auth-with-oauth2",a).then((e=>this.authResponse(e)))}authWithOAuth2(...e){if(e.length>1||"string"==typeof e?.[0])return console.warn("PocketBase: This form of authWithOAuth2() is deprecated and may get removed in the future. Please replace with authWithOAuth2Code() OR use the authWithOAuth2() realtime form as shown in https://pocketbase.io/docs/authentication/#oauth2-integration."),this.authWithOAuth2Code(e?.[0]||"",e?.[1]||"",e?.[2]||"",e?.[3]||"",e?.[4]||{},e?.[5]||{},e?.[6]||{});const t=e?.[0]||{};let s=null;t.urlCallback||(s=openBrowserPopup(void 0));const i=new RealtimeService(this.client);function cleanup(){s?.close(),i.unsubscribe()}const n={},r=t.requestKey;return r&&(n.requestKey=r),this.listAuthMethods(n).then((e=>{const n=e.oauth2.providers.find((e=>e.name===t.provider));if(!n)throw new ClientResponseError(new Error(`Missing or invalid provider "${t.provider}".`));const o=this.client.buildURL("/api/oauth2-redirect");return new Promise((async(e,a)=>{const c=r?this.client.cancelControllers?.[r]:void 0;c&&(c.signal.onabort=()=>{cleanup(),a(new ClientResponseError({isAbort:!0,message:"manually cancelled"}))}),i.onDisconnect=e=>{e.length&&a&&(cleanup(),a(new ClientResponseError(new Error("realtime connection interrupted"))))};try{await i.subscribe("@oauth2",(async s=>{const r=i.clientId;try{if(!s.state||r!==s.state)throw new Error("State parameters don't match.");if(s.error||!s.code)throw new Error("OAuth2 redirect error or missing code: "+s.error);const i=Object.assign({},t);delete i.provider,delete i.scopes,delete i.createData,delete i.urlCallback,c?.signal?.onabort&&(c.signal.onabort=null);const a=await this.authWithOAuth2Code(n.name,s.code,n.codeVerifier,o,t.createData,i);e(a)}catch(e){a(new ClientResponseError(e))}cleanup()}));const r={state:i.clientId};t.scopes?.length&&(r.scope=t.scopes.join(" "));const l=this._replaceQueryParams(n.authURL+o,r);let h=t.urlCallback||function(e){s?s.location.href=e:s=openBrowserPopup(e)};await h(l)}catch(e){c?.signal?.onabort&&(c.signal.onabort=null),cleanup(),a(new ClientResponseError(e))}}))})).catch((e=>{throw cleanup(),e}))}async authRefresh(e,t){let s={method:"POST"};return s=normalizeLegacyOptionsArgs("This form of authRefresh(body?, query?) is deprecated. Consider replacing it with authRefresh(options?).",s,e,t),this.client.send(this.baseCollectionPath+"/auth-refresh",s).then((e=>this.authResponse(e)))}async requestPasswordReset(e,t,s){let i={method:"POST",body:{email:e}};return i=normalizeLegacyOptionsArgs("This form of requestPasswordReset(email, body?, query?) is deprecated. Consider replacing it with requestPasswordReset(email, options?).",i,t,s),this.client.send(this.baseCollectionPath+"/request-password-reset",i).then((()=>!0))}async confirmPasswordReset(e,t,s,i,n){let r={method:"POST",body:{token:e,password:t,passwordConfirm:s}};return r=normalizeLegacyOptionsArgs("This form of confirmPasswordReset(token, password, passwordConfirm, body?, query?) is deprecated. Consider replacing it with confirmPasswordReset(token, password, passwordConfirm, options?).",r,i,n),this.client.send(this.baseCollectionPath+"/confirm-password-reset",r).then((()=>!0))}async requestVerification(e,t,s){let i={method:"POST",body:{email:e}};return i=normalizeLegacyOptionsArgs("This form of requestVerification(email, body?, query?) is deprecated. Consider replacing it with requestVerification(email, options?).",i,t,s),this.client.send(this.baseCollectionPath+"/request-verification",i).then((()=>!0))}async confirmVerification(e,t,s){let i={method:"POST",body:{token:e}};return i=normalizeLegacyOptionsArgs("This form of confirmVerification(token, body?, query?) is deprecated. Consider replacing it with confirmVerification(token, options?).",i,t,s),this.client.send(this.baseCollectionPath+"/confirm-verification",i).then((()=>{const t=getTokenPayload(e),s=this.client.authStore.record;return s&&!s.verified&&s.id===t.id&&s.collectionId===t.collectionId&&(s.verified=!0,this.client.authStore.save(this.client.authStore.token,s)),!0}))}async requestEmailChange(e,t,s){let i={method:"POST",body:{newEmail:e}};return i=normalizeLegacyOptionsArgs("This form of requestEmailChange(newEmail, body?, query?) is deprecated. Consider replacing it with requestEmailChange(newEmail, options?).",i,t,s),this.client.send(this.baseCollectionPath+"/request-email-change",i).then((()=>!0))}async confirmEmailChange(e,t,s,i){let n={method:"POST",body:{token:e,password:t}};return n=normalizeLegacyOptionsArgs("This form of confirmEmailChange(token, password, body?, query?) is deprecated. Consider replacing it with confirmEmailChange(token, password, options?).",n,s,i),this.client.send(this.baseCollectionPath+"/confirm-email-change",n).then((()=>{const t=getTokenPayload(e),s=this.client.authStore.record;return s&&s.id===t.id&&s.collectionId===t.collectionId&&this.client.authStore.clear(),!0}))}async listExternalAuths(e,t){return this.client.collection("_externalAuths").getFullList(Object.assign({},t,{filter:this.client.filter("recordRef = {:id}",{id:e})}))}async unlinkExternalAuth(e,t,s){const i=await this.client.collection("_externalAuths").getFirstListItem(this.client.filter("recordRef = {:recordId} && provider = {:provider}",{recordId:e,provider:t}));return this.client.collection("_externalAuths").delete(i.id,s).then((()=>!0))}async requestOTP(e,t){return t=Object.assign({method:"POST",body:{email:e}},t),this.client.send(this.baseCollectionPath+"/request-otp",t)}async authWithOTP(e,t,s){return s=Object.assign({method:"POST",body:{otpId:e,password:t}},s),this.client.send(this.baseCollectionPath+"/auth-with-otp",s).then((e=>this.authResponse(e)))}async impersonate(e,t,s){(s=Object.assign({method:"POST",body:{duration:t}},s)).headers=s.headers||{},s.headers.Authorization||(s.headers.Authorization=this.client.authStore.token);const i=new Client(this.client.baseURL,new BaseAuthStore,this.client.lang),n=await i.send(this.baseCollectionPath+"/impersonate/"+encodeURIComponent(e),s);return i.authStore.save(n?.token,this.decode(n?.record||{})),i}_replaceQueryParams(e,t={}){let s=e,i="";e.indexOf("?")>=0&&(s=e.substring(0,e.indexOf("?")),i=e.substring(e.indexOf("?")+1));const n={},r=i.split("&");for(const e of r){if(""==e)continue;const t=e.split("=");n[decodeURIComponent(t[0].replace(/\+/g," "))]=decodeURIComponent((t[1]||"").replace(/\+/g," "))}for(let e in t)t.hasOwnProperty(e)&&(null==t[e]?delete n[e]:n[e]=t[e]);i="";for(let e in n)n.hasOwnProperty(e)&&(""!=i&&(i+="&"),i+=encodeURIComponent(e.replace(/%20/g,"+"))+"="+encodeURIComponent(n[e].replace(/%20/g,"+")));return""!=i?s+"?"+i:s}}function openBrowserPopup(e){if("undefined"==typeof window||!window?.open)throw new ClientResponseError(new Error("Not in a browser context - please pass a custom urlCallback function."));let t=1024,s=768,i=window.innerWidth,n=window.innerHeight;t=t>i?i:t,s=s>n?n:s;let r=i/2-t/2,o=n/2-s/2;return window.open(e,"popup_window","width="+t+",height="+s+",top="+o+",left="+r+",resizable,menubar=no")}class CollectionService extends CrudService{get baseCrudPath(){return"/api/collections"}async import(e,t=!1,s){return s=Object.assign({method:"PUT",body:{collections:e,deleteMissing:t}},s),this.client.send(this.baseCrudPath+"/import",s).then((()=>!0))}async truncate(e,t){return t=Object.assign({method:"DELETE"},t),this.client.send(this.baseCrudPath+"/"+encodeURIComponent(e)+"/truncate",t).then((()=>!0))}async getScaffolds(e){return e=Object.assign({method:"GET"},e),this.client.send(this.baseCrudPath+"/meta/scaffolds",e)}async getAllOAuth2Providers(e){return e=Object.assign({method:"GET"},e),this.client.send(this.baseCrudPath+"/meta/oauth2-providers",e)}async dryRunViewQuery(e,t){return t=Object.assign({method:"POST",body:{query:e}},t),this.client.send(this.baseCrudPath+"/meta/dry-run-view",t)}}class LogService extends BaseService{async getList(e=1,t=30,s){return(s=Object.assign({method:"GET"},s)).query=Object.assign({page:e,perPage:t},s.query),this.client.send("/api/logs",s)}async getOne(e,t){if(!e)throw new ClientResponseError({url:this.client.buildURL("/api/logs/"),status:404,response:{code:404,message:"Missing required log id.",data:{}}});return t=Object.assign({method:"GET"},t),this.client.send("/api/logs/"+encodeURIComponent(e),t)}async getStats(e){return e=Object.assign({method:"GET"},e),this.client.send("/api/logs/stats",e)}async truncate(e){return e=Object.assign({method:"DELETE"},e),this.client.send("/api/logs",e).then((()=>!0))}}class HealthService extends BaseService{async check(e){return e=Object.assign({method:"GET"},e),this.client.send("/api/health",e)}}class FileService extends BaseService{getUrl(e,t,s={}){return console.warn("Please replace pb.files.getUrl() with pb.files.getURL()"),this.getURL(e,t,s)}getURL(e,t,s={}){if(!t||!e?.id||!e?.collectionId&&!e?.collectionName)return"";const i=[];i.push("api"),i.push("files"),i.push(encodeURIComponent(e.collectionId||e.collectionName)),i.push(encodeURIComponent(e.id)),i.push(encodeURIComponent(t));let n=this.client.buildURL(i.join("/"));!1===s.download&&delete s.download;const r=serializeQueryParams(s);return r&&(n+=(n.includes("?")?"&":"?")+r),n}async getToken(e){return e=Object.assign({method:"POST"},e),this.client.send("/api/files/token",e).then((e=>e?.token||""))}}class BackupService extends BaseService{async getFullList(e){return e=Object.assign({method:"GET"},e),this.client.send("/api/backups",e)}async create(e,t){return t=Object.assign({method:"POST",body:{name:e}},t),this.client.send("/api/backups",t).then((()=>!0))}async upload(e,t){return t=Object.assign({method:"POST",body:e},t),this.client.send("/api/backups/upload",t).then((()=>!0))}async delete(e,t){return t=Object.assign({method:"DELETE"},t),this.client.send(`/api/backups/${encodeURIComponent(e)}`,t).then((()=>!0))}async restore(e,t){return t=Object.assign({method:"POST"},t),this.client.send(`/api/backups/${encodeURIComponent(e)}/restore`,t).then((()=>!0))}getDownloadUrl(e,t){return console.warn("Please replace pb.backups.getDownloadUrl() with pb.backups.getDownloadURL()"),this.getDownloadURL(e,t)}getDownloadURL(e,t){return this.client.buildURL(`/api/backups/${encodeURIComponent(t)}?token=${encodeURIComponent(e)}`)}}class CronService extends BaseService{async getFullList(e){return e=Object.assign({method:"GET"},e),this.client.send("/api/crons",e)}async run(e,t){return t=Object.assign({method:"POST"},t),this.client.send(`/api/crons/${encodeURIComponent(e)}`,t).then((()=>!0))}}class SQLService extends BaseService{async run(e,t){return t=Object.assign({method:"POST",body:{query:e}},t),this.client.send("/api/sql",t)}}function isFile(e){return"undefined"!=typeof Blob&&e instanceof Blob||"undefined"!=typeof File&&e instanceof File||null!==e&&"object"==typeof e&&e.uri&&("undefined"!=typeof navigator&&"ReactNative"===navigator.product||"undefined"!=typeof global&&global.HermesInternal)}function isFormData(e){return e&&("FormData"===e.constructor?.name||"undefined"!=typeof FormData&&e instanceof FormData)}function hasFileField(e){for(const t in e){const s=Array.isArray(e[t])?e[t]:[e[t]];for(const e of s)if(isFile(e))return!0}return!1}const r=/^[\-\.\d]+$/;function inferFormDataValue(e){if("string"!=typeof e)return e;if("true"==e)return!0;if("false"==e)return!1;if(("-"===e[0]||e[0]>="0"&&e[0]<="9")&&r.test(e)){let t=+e;if(""+t===e)return t}return e}class BatchService extends BaseService{constructor(){super(...arguments),this.requests=[],this.subs={}}collection(e){return this.subs[e]||(this.subs[e]=new SubBatchService(this.requests,e)),this.subs[e]}async send(e){const t=new FormData,s=[];for(let e=0;e{if("@jsonPayload"===s&&"string"==typeof e)try{let s=JSON.parse(e);Object.assign(t,s)}catch(e){console.warn("@jsonPayload error:",e)}else void 0!==t[s]?(Array.isArray(t[s])||(t[s]=[t[s]]),t[s].push(inferFormDataValue(e))):t[s]=inferFormDataValue(e)})),t}(s));for(const t in s){const i=s[t];if(isFile(i))e.files[t]=e.files[t]||[],e.files[t].push(i);else if(Array.isArray(i)){const s=[],n=[];for(const e of i)isFile(e)?s.push(e):n.push(e);if(s.length>0&&s.length==i.length){e.files[t]=e.files[t]||[];for(let i of s)e.files[t].push(i)}else if(e.json[t]=n,s.length>0){let i=t;t.startsWith("+")||t.endsWith("+")||(i+="+"),e.files[i]=e.files[i]||[];for(let t of s)e.files[i].push(t)}}else e.json[t]=i}}}RegExp.escape||(RegExp.escape=function(e){return e.replace(/[/\-\\^$*+?.()|[\]{}]/g,"\\$&")});class Client{get baseUrl(){return this.baseURL}set baseUrl(e){this.baseURL=e}constructor(e="/",t,s="en-US"){this.cancelControllers={},this.recordServices={},this.enableAutoCancellation=!0,this.baseURL=e,this.lang=s,t?this.authStore=t:"undefined"!=typeof window&&window.Deno?this.authStore=new BaseAuthStore:this.authStore=new LocalAuthStore,this.collections=new CollectionService(this),this.files=new FileService(this),this.logs=new LogService(this),this.settings=new SettingsService(this),this.realtime=new RealtimeService(this),this.health=new HealthService(this),this.backups=new BackupService(this),this.crons=new CronService(this),this.sql=new SQLService(this)}get admins(){return this.collection("_superusers")}createBatch(){return new BatchService(this)}collection(e){return this.recordServices[e]||(this.recordServices[e]=new RecordService(this,e)),this.recordServices[e]}autoCancellation(e){return this.enableAutoCancellation=!!e,this}cancelRequest(e){return this.cancelControllers[e]&&(this.cancelControllers[e].abort(),delete this.cancelControllers[e]),this}cancelAllRequests(){for(let e in this.cancelControllers)this.cancelControllers[e].abort();return this.cancelControllers={},this}filter(e,t){if(!t)return e;const s={};for(const e in t)s["{:"+e+"}"]=t[e];const i=Object.keys(s).map((e=>RegExp.escape(e))).join("|");if(!i)return e;return e.replace(new RegExp(i,"g"),(e=>function stringify(e){switch(typeof e){case"boolean":case"number":return""+e;case"string":return JSON.stringify(e);default:if(null==e)return"null";if(e instanceof Date)return JSON.stringify(e.toISOString().replace("T"," "));const t=JSON.stringify(e);return t.startsWith("[")||t.startsWith("{")?JSON.stringify(t):t}}(s[e])))}getFileUrl(e,t,s={}){return console.warn("Please replace pb.getFileUrl() with pb.files.getURL()"),this.files.getURL(e,t,s)}buildUrl(e){return console.warn("Please replace pb.buildUrl() with pb.buildURL()"),this.buildURL(e)}buildURL(e){let t=this.baseURL;return"undefined"==typeof window||!window.location||t.startsWith("https://")||t.startsWith("http://")||(t=window.location.origin?.endsWith("/")?window.location.origin.substring(0,window.location.origin.length-1):window.location.origin||"",this.baseURL.startsWith("/")||(t+=window.location.pathname||"/",t+=t.endsWith("/")?"":"/"),t+=this.baseURL),e&&(t+=t.endsWith("/")?"":"/",t+=e.startsWith("/")?e.substring(1):e),t}async send(e,t){t=this.initSendOptions(e,t);let s=this.buildURL(e);if(this.beforeSend){const e=Object.assign({},await this.beforeSend(s,t));void 0!==e.url||void 0!==e.options?(s=e.url||s,t=e.options||t):Object.keys(e).length&&(t=e,console?.warn&&console.warn("Deprecated format of beforeSend return: please use `return { url, options }`, instead of `return options`."))}if(void 0!==t.query){const e=serializeQueryParams(t.query);e&&(s+=(s.includes("?")?"&":"?")+e),delete t.query}"application/json"==this.getHeader(t.headers,"Content-Type")&&t.body&&"string"!=typeof t.body&&(t.body=JSON.stringify(t.body));return(t.fetch||fetch)(s,t).then((async e=>{let s={};try{s=await e.json()}catch(e){if(t.signal?.aborted||"AbortError"==e?.name||"Aborted"==e?.message)throw e}if(this.afterSend&&(s=await this.afterSend(e,s,t)),e.status>=400)throw new ClientResponseError({url:e.url,status:e.status,data:s});return s})).catch((e=>{throw new ClientResponseError(e)}))}initSendOptions(e,t){if((t=Object.assign({method:"GET"},t)).body=function convertToFormDataIfNeeded(e){if("undefined"==typeof FormData||void 0===e||"object"!=typeof e||null===e||isFormData(e)||!hasFileField(e))return e;const t=new FormData;for(const s in e){const i=e[s];if(void 0!==i)if("object"!=typeof i||hasFileField({data:i})){const e=Array.isArray(i)?i:[i];for(let i of e)t.append(s,i)}else{let e={};e[s]=i,t.append("@jsonPayload",JSON.stringify(e))}}return t}(t.body),normalizeUnknownQueryParams(t),t.query=Object.assign({},t.params,t.query),void 0===t.requestKey&&(!1===t.$autoCancel||!1===t.query.$autoCancel?t.requestKey=null:(t.$cancelKey||t.query.$cancelKey)&&(t.requestKey=t.$cancelKey||t.query.$cancelKey)),delete t.$autoCancel,delete t.query.$autoCancel,delete t.$cancelKey,delete t.query.$cancelKey,null!==this.getHeader(t.headers,"Content-Type")||isFormData(t.body)||(t.headers=Object.assign({},t.headers,{"Content-Type":"application/json"})),null===this.getHeader(t.headers,"Accept-Language")&&(t.headers=Object.assign({},t.headers,{"Accept-Language":this.lang})),this.authStore.token&&null===this.getHeader(t.headers,"Authorization")&&(t.headers=Object.assign({},t.headers,{Authorization:this.authStore.token})),this.enableAutoCancellation&&null!==t.requestKey){const s=t.requestKey||(t.method||"GET")+e;delete t.requestKey,this.cancelRequest(s);const i=new AbortController;this.cancelControllers[s]=i,t.signal=i.signal}return t}getHeader(e,t){e=e||{},t=t.toLowerCase();for(let s in e)if(s.toLowerCase()==t)return e[s];return null}}return Client})); +//# sourceMappingURL=pocketbase.umd.js.map diff --git a/site/assets/v2.css b/site/assets/v2.css index 772618b73..5734472d4 100644 --- a/site/assets/v2.css +++ b/site/assets/v2.css @@ -266,6 +266,17 @@ body.login-page > *{position:relative;z-index:1} } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/en/index.html b/site/en/index.html index cc58eaeb0..a2f2aa77e 100644 --- a/site/en/index.html +++ b/site/en/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/es/index.html b/site/es/index.html index c6e6e332d..659c6e142 100644 --- a/site/es/index.html +++ b/site/es/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/id/index.html b/site/id/index.html index 6665a4ab6..b4d453e47 100644 --- a/site/id/index.html +++ b/site/id/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/login/index.html b/site/login/index.html index 380d255c5..c2b433b83 100644 --- a/site/login/index.html +++ b/site/login/index.html @@ -49,6 +49,8 @@

Sign in

+ + @@ -63,6 +65,8 @@

Sign in

google: 'Continue with Google', github: 'Continue with GitHub', back: 'Back to the book', + working: 'Waiting for sign-in…', + error: 'Sign-in failed. Close the popup and try again.', docTitle: 'Sign in · HowToLiveBetter' }, ru: { @@ -71,6 +75,8 @@

Sign in

google: 'Войти через Google', github: 'Войти через GitHub', back: 'Обратно к книге', + working: 'Ждём вход…', + error: 'Не удалось войти. Закройте окно и попробуйте снова.', docTitle: 'Вход · HowToLiveBetter' }, zh: { @@ -79,6 +85,8 @@

Sign in

google: '使用 Google 登录', github: '使用 GitHub 登录', back: '返回书籍', + working: '正在等待登录…', + error: '登录失败。请关闭弹窗后重试。', docTitle: '登录 · HowToLiveBetter' }, es: { @@ -87,6 +95,8 @@

Sign in

google: 'Continuar con Google', github: 'Continuar con GitHub', back: 'De vuelta al libro', + working: 'Esperando el inicio de sesión…', + error: 'No se pudo iniciar sesión. Cierra la ventana e inténtalo de nuevo.', docTitle: 'Iniciar sesión · HowToLiveBetter' }, pt: { @@ -95,6 +105,8 @@

Sign in

google: 'Continuar com Google', github: 'Continuar com GitHub', back: 'De volta ao livro', + working: 'A aguardar o início de sessão…', + error: 'Falha ao entrar. Feche a janela e tente de novo.', docTitle: 'Entrar · HowToLiveBetter' }, ar: { @@ -103,6 +115,8 @@

Sign in

google: 'المتابعة مع Google', github: 'المتابعة مع GitHub', back: 'العودة إلى الكتاب', + working: 'بانتظار تسجيل الدخول…', + error: 'فشل تسجيل الدخول. أغلق النافذة وحاول مرة أخرى.', docTitle: 'تسجيل الدخول · HowToLiveBetter' }, id: { @@ -111,6 +125,8 @@

Sign in

google: 'Lanjut dengan Google', github: 'Lanjut dengan GitHub', back: 'Kembali ke buku', + working: 'Menunggu masuk…', + error: 'Gagal masuk. Tutup jendela dan coba lagi.', docTitle: 'Masuk · HowToLiveBetter' } }; @@ -149,7 +165,9 @@

Sign in

accountSignIn: t.title, accountLogout: lang === 'ru' ? 'Выйти' : lang === 'zh' ? '退出' : 'Log out', accountSignedIn: lang === 'ru' ? 'Вы вошли' : lang === 'zh' ? '已登录' : 'Signed in', - accountAria: t.title + accountAria: t.title, + loginWorking: t.working, + loginError: t.error }; }; } diff --git a/site/pt/index.html b/site/pt/index.html index d210a9e1c..db6c3cb54 100644 --- a/site/pt/index.html +++ b/site/pt/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/ru/index.html b/site/ru/index.html index 16737363f..8027cfb1a 100644 --- a/site/ru/index.html +++ b/site/ru/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center; diff --git a/site/zh/index.html b/site/zh/index.html index 8b42432ff..96003d2a0 100644 --- a/site/zh/index.html +++ b/site/zh/index.html @@ -385,6 +385,17 @@ } .login-provider-icon svg{display:block;width:var(--sp-5);height:var(--sp-5)} .login-provider-label{flex:0 1 auto} +.login-status{ + margin:0; + min-height:1.4em; + font:var(--fw-ui) var(--fs-ui)/var(--lh-ui) var(--sans); + color:var(--mut); + text-align:center; + text-wrap:pretty; +} +.login-status[data-tone="error"]{color:var(--ink)} +.login-status[hidden]{display:none} +.login-provider[disabled]{opacity:.55;cursor:wait} .login-back{ display:block; align-self:center;