mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-19 05:44:01 +03:00
Compare commits
5 Commits
feat/huma-
...
invite-ema
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70aab2143d | ||
|
|
b254d969a5 | ||
|
|
31c416ab2c | ||
|
|
bcde61b8fb | ||
|
|
fdfaac8a35 |
6
.github/workflows/build-next.yml
vendored
6
.github/workflows/build-next.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -82,11 +82,11 @@ jobs:
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -76,12 +76,12 @@ jobs:
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
|
||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -4,9 +4,9 @@ go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-contrib/slog v1.2.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/go-co-op/gocron/v2 v2.22.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
@@ -32,7 +32,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/libtnb/sqlite v1.1.2
|
||||
github.com/libtnb/sqlite v1.2.0
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
@@ -53,10 +53,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/text v0.40.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
@@ -231,7 +231,7 @@ require (
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
|
||||
@@ -22,10 +22,10 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
@@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
|
||||
@@ -161,8 +161,8 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -348,8 +348,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libtnb/sqlite v1.1.2 h1:3g8nez8MwdEhn+oqt3bfKWnYJxk0ZGTO0kVA1WT+hjQ=
|
||||
github.com/libtnb/sqlite v1.1.2/go.mod h1:2PGCWOR6HqqXuoIVMmwfJ655epvRO8sw0b9kHWuKNsQ=
|
||||
github.com/libtnb/sqlite v1.2.0 h1:XsA7jsXHH2qmFkTWoy5YCKJybzHzWQ0flqDFP5Y9Yto=
|
||||
github.com/libtnb/sqlite v1.2.0/go.mod h1:O6vURH5fa5IgSmXd/qLAL2zSnYFUd7xSIumsmX3BrSI=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -618,13 +618,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -688,8 +688,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -711,8 +711,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -277,3 +277,13 @@ type InvalidEmailVerificationTokenError struct{}
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type EmailDomainNotAllowedError struct {
|
||||
Domain string
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) Error() string {
|
||||
return fmt.Sprintf("The email address must use the domain @%s", e.Domain)
|
||||
}
|
||||
|
||||
func (e EmailDomainNotAllowedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -21,6 +21,9 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
|
||||
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
// emailDomainRegex validates the domain part of an email address (e.g. "example.com" or "mail.example.co.uk")
|
||||
var emailDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
||||
|
||||
func init() {
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
@@ -34,6 +37,9 @@ func init() {
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"email_domain": func(fl validator.FieldLevel) bool {
|
||||
return ValidateEmailDomain(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
@@ -106,3 +112,22 @@ func ValidateCallbackURL(str string) bool {
|
||||
func ValidateCallbackURLPattern(raw string) bool {
|
||||
return utils.ValidateCallbackURLPattern(raw) == nil
|
||||
}
|
||||
|
||||
// ValidateEmailDomain validates an optional email domain.
|
||||
// It accepts inputs with or without a leading "@"
|
||||
func ValidateEmailDomain(domain string) bool {
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
domain = strings.TrimPrefix(strings.ToLower(domain), "@")
|
||||
if domain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
if !emailDomainRegex.MatchString(domain) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -112,6 +112,42 @@ func TestValidateCallbackURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEmailDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid simple", "example.com", true},
|
||||
{"valid with leading at", "@example.com", true},
|
||||
{"valid subdomain", "mail.example.co.uk", true},
|
||||
{"valid uppercase", "EXAMPLE.COM", true},
|
||||
{"valid with at and uppercase", "@Example.Com", true},
|
||||
{"valid with hyphen", "my-domain.com", true},
|
||||
{"valid with digits", "example123.com", true},
|
||||
{"valid single char label", "a.com", true},
|
||||
{"empty", "", false},
|
||||
{"only at", "@", false},
|
||||
{"no tld", "example", false},
|
||||
{"tld too short", "example.c", false},
|
||||
{"trailing dot", "example.com.", false},
|
||||
{"leading dot", ".example.com", false},
|
||||
{"double dot", "example..com", false},
|
||||
{"label starts with hyphen", "-example.com", false},
|
||||
{"label ends with hyphen", "example-.com", false},
|
||||
{"numeric tld", "example.123", false},
|
||||
{"contains space", "exa mple.com", false},
|
||||
{"double at", "@@example.com", false},
|
||||
{"trailing space", "example.com ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateEmailDomain(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -486,6 +486,16 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
UsageLimit: 1,
|
||||
UsageCount: 1, // Usage limit reached
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6a7-4890-bcde-f01234567891",
|
||||
},
|
||||
Token: "DOMAIN1234567890",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
UsageLimit: 5,
|
||||
UsageCount: 0,
|
||||
EmailDomain: new("example.com"),
|
||||
},
|
||||
}
|
||||
for _, token := range signupTokens {
|
||||
if err := tx.Create(&token).Error; err != nil {
|
||||
|
||||
@@ -301,15 +301,26 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Bump the UpdatedAt timestamp of the groups the new user was added to
|
||||
// This is necessary for SCIM to work with the newly-created user, or groups may not be synced via SCIM
|
||||
if len(userGroups) > 0 {
|
||||
err = s.touchUserGroups(ctx, tx, groupIDs(userGroups))
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultGroups(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultCustomClaims(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
@@ -348,11 +359,43 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to associate default user groups: %w", err)
|
||||
}
|
||||
|
||||
// Bump the groups' UpdatedAt so the SCIM sync picks up the new
|
||||
// membership (see touchUserGroups for details).
|
||||
touchIDs := make([]string, len(groups))
|
||||
for i := range groups {
|
||||
touchIDs[i] = groups[i].ID
|
||||
}
|
||||
if err := s.touchUserGroups(ctx, tx, touchIDs); err != nil {
|
||||
return fmt.Errorf("failed to update default user groups timestamp: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// touchUserGroups updates the UpdatedAt timestamp of the given user groups.
|
||||
//
|
||||
// Group membership is stored in the user_groups_users join table, so adding or
|
||||
// removing a member does not modify the group row itself. The SCIM sync only
|
||||
// re-pushes a group to the provider when its UpdatedAt is not older than the
|
||||
// remote resource's last-modified time, so any code path that changes a group's
|
||||
// membership must bump this timestamp explicitly. Otherwise the membership
|
||||
// change is never synced to the SCIM provider.
|
||||
func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := datatype.DateTime(time.Now())
|
||||
return tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.UserGroup{}).
|
||||
Where("id IN ?", ids).
|
||||
Update("updated_at", now).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
|
||||
104
backend/internal/service/user_service_test.go
Normal file
104
backend/internal/service/user_service_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
|
||||
return userService, groupService
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "members",
|
||||
FriendlyName: "Members",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, group.UpdatedAt, "a freshly created group has no UpdatedAt yet")
|
||||
|
||||
// Create a user that is a member of the group
|
||||
// This mirrors signing up via an invite link that adds the user to a group
|
||||
email := "member@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "member",
|
||||
Email: &email,
|
||||
FirstName: "Group",
|
||||
LastName: "Member",
|
||||
UserGroupIds: []string{group.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The group's UpdatedAt must now be set
|
||||
updated, err := groupService.Get(t.Context(), group.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated.UpdatedAt, "creating a group member must bump the group's UpdatedAt")
|
||||
require.False(t, updated.LastModified().Before(updated.CreatedAt.ToTime()), "group LastModified must not predate its CreatedAt after a membership change")
|
||||
require.Len(t, updated.Users, 1, "the user should be a member of the group")
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "default",
|
||||
FriendlyName: "Default",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, group.UpdatedAt)
|
||||
|
||||
// Configure the group as a default signup group
|
||||
defaultGroups, err := json.Marshal([]string{group.ID})
|
||||
require.NoError(t, err)
|
||||
appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups)
|
||||
|
||||
// Create a user without explicit group IDs, so the default groups apply
|
||||
email := "default@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "defaultmember",
|
||||
Email: &email,
|
||||
FirstName: "Default",
|
||||
LastName: "Member",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := groupService.Get(t.Context(), group.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated.UpdatedAt, "adding a default group member must bump the group's UpdatedAt")
|
||||
require.Len(t, updated.Users, 1)
|
||||
}
|
||||
@@ -18,14 +18,21 @@ type signupTokenCreateDto struct {
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
UserGroupIDs []string `json:"userGroupIds"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
type signupTokenDto struct {
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
UsageLimit int `json:"usageLimit"`
|
||||
UsageCount int `json:"usageCount"`
|
||||
EmailDomain *string `json:"emailDomain" binding:"omitempty,email_domain"`
|
||||
UserGroups []dto.UserGroupMinimalDto `json:"userGroups"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
}
|
||||
|
||||
// signupTokenInfoDto exposes the limited, publicly readable metadata of a signup token
|
||||
type signupTokenInfoDto struct {
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ func (h *handler) createSignupToken(c *gin.Context) {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
|
||||
signupToken, err := h.service.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs, input.EmailDomain)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -159,6 +159,28 @@ func (h *handler) deleteSignupToken(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signupTokenInfoHandler godoc
|
||||
// @Summary Get signup token info
|
||||
// @Description Get the public metadata (such as the required email domain) of a signup token
|
||||
// @Tags Users
|
||||
// @Produce json
|
||||
// @Param token path string true "Signup token"
|
||||
// @Success 200 {object} signupTokenInfoDto
|
||||
// @Router /api/signup/token/{token} [get]
|
||||
func (h *handler) signupTokenInfo(c *gin.Context) {
|
||||
token := c.Param("token")
|
||||
|
||||
signupToken, err := h.service.GetSignupTokenInfo(c.Request.Context(), token)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, signupTokenInfoDto{
|
||||
EmailDomain: signupToken.EmailDomain,
|
||||
})
|
||||
}
|
||||
|
||||
// signupHandler godoc
|
||||
// @Summary Sign up
|
||||
// @Description Create a new user account
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -11,11 +12,12 @@ import (
|
||||
type SignupToken struct {
|
||||
model.Base
|
||||
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||
UsageCount int `json:"usageCount" sortable:"true"`
|
||||
EmailDomain *string `json:"emailDomain"`
|
||||
UserGroups []model.UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
|
||||
}
|
||||
|
||||
func (st *SignupToken) IsExpired() bool {
|
||||
@@ -29,3 +31,24 @@ func (st *SignupToken) IsUsageLimitReached() bool {
|
||||
func (st *SignupToken) IsValid() bool {
|
||||
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||
}
|
||||
|
||||
// HasEmailDomainRestriction reports whether the token limits sign-ups to a specific email domain
|
||||
func (st *SignupToken) HasEmailDomainRestriction() bool {
|
||||
return st.EmailDomain != nil && *st.EmailDomain != ""
|
||||
}
|
||||
|
||||
// EmailMatchesDomain reports whether the given email address is allowed by the token's domain restriction
|
||||
// It returns true when the token has no restriction
|
||||
// The comparison is case-insensitive
|
||||
func (st *SignupToken) EmailMatchesDomain(email string) bool {
|
||||
if !st.HasEmailDomainRestriction() {
|
||||
return true
|
||||
}
|
||||
|
||||
at := strings.LastIndexByte(email, '@')
|
||||
if at < 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.EqualFold(email[at+1:], *st.EmailDomain)
|
||||
}
|
||||
|
||||
39
backend/internal/usersignup/models_test.go
Normal file
39
backend/internal/usersignup/models_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package usersignup
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func TestSignupTokenEmailMatchesDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
emailDomain *string
|
||||
email string
|
||||
want bool
|
||||
}{
|
||||
{name: "no restriction allows any email", emailDomain: nil, email: "user@anything.com", want: true},
|
||||
{name: "empty restriction allows any email", emailDomain: strPtr(""), email: "user@anything.com", want: true},
|
||||
{name: "matching domain", emailDomain: strPtr("example.com"), email: "user@example.com", want: true},
|
||||
{name: "matching domain case-insensitive", emailDomain: strPtr("example.com"), email: "User@Example.COM", want: true},
|
||||
{name: "non-matching domain", emailDomain: strPtr("example.com"), email: "user@other.com", want: false},
|
||||
{name: "subdomain does not match", emailDomain: strPtr("example.com"), email: "user@mail.example.com", want: false},
|
||||
{name: "domain suffix does not match", emailDomain: strPtr("example.com"), email: "user@notexample.com", want: false},
|
||||
{name: "missing @ with restriction", emailDomain: strPtr("example.com"), email: "userexample.com", want: false},
|
||||
{name: "empty email with restriction", emailDomain: strPtr("example.com"), email: "", want: false},
|
||||
{name: "plus addressing still matches", emailDomain: strPtr("example.com"), email: "user+tag@example.com", want: true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
st := &SignupToken{EmailDomain: tc.emailDomain}
|
||||
got := st.EmailMatchesDomain(tc.email)
|
||||
if got != tc.want {
|
||||
t.Errorf("EmailMatchesDomain(%q) with domain %v = %v, want %v", tc.email, tc.emailDomain, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth, signupRate
|
||||
apiGroup.GET("/signup-tokens", adminAuth, m.handler.listSignupTokens)
|
||||
apiGroup.DELETE("/signup-tokens/:id", adminAuth, m.handler.deleteSignupToken)
|
||||
apiGroup.POST("/signup", signupRateLimit, m.handler.signup)
|
||||
apiGroup.GET("/signup/token/:token", signupRateLimit, m.handler.signupTokenInfo)
|
||||
apiGroup.GET("/signup/setup", m.handler.checkInitialAdminSetupAvailable)
|
||||
apiGroup.POST("/signup/setup", m.handler.signUpInitialAdmin)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,16 @@ func (s *Service) SignUp(ctx context.Context, signupData signUpDto, ipAddress, u
|
||||
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
|
||||
}
|
||||
|
||||
if signupToken.HasEmailDomainRestriction() {
|
||||
email := ""
|
||||
if signupData.Email != nil {
|
||||
email = *signupData.Email
|
||||
}
|
||||
if !signupToken.EmailMatchesDomain(email) {
|
||||
return model.User{}, "", &common.EmailDomainNotAllowedError{Domain: *signupToken.EmailDomain}
|
||||
}
|
||||
}
|
||||
|
||||
for _, group := range signupToken.UserGroups {
|
||||
userGroupIDs = append(userGroupIDs, group.ID)
|
||||
}
|
||||
@@ -190,8 +200,26 @@ func (s *Service) DeleteSignupToken(ctx context.Context, tokenID string) error {
|
||||
return s.db.WithContext(ctx).Delete(&SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit)
|
||||
// GetSignupTokenInfo returns a signup token by its token string.
|
||||
// It's used to expose the limited, public metadata (such as the required email domain) needed to render the signup form.
|
||||
func (s *Service) GetSignupTokenInfo(ctx context.Context, token string) (SignupToken, error) {
|
||||
var signupToken SignupToken
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("token = ?", token).
|
||||
First(&signupToken).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SignupToken{}, &common.TokenInvalidOrExpiredError{}
|
||||
} else if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
|
||||
return signupToken, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string, emailDomain *string) (SignupToken, error) {
|
||||
signupToken, err := newSignupToken(ttl, usageLimit, emailDomain)
|
||||
if err != nil {
|
||||
return SignupToken{}, err
|
||||
}
|
||||
@@ -214,7 +242,7 @@ func (s *Service) CreateSignupToken(ctx context.Context, ttl time.Duration, usag
|
||||
return *signupToken, nil
|
||||
}
|
||||
|
||||
func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
func newSignupToken(ttl time.Duration, usageLimit int, emailDomain *string) (*SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
@@ -223,10 +251,11 @@ func newSignupToken(ttl time.Duration, usageLimit int) (*SignupToken, error) {
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
token := &SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
EmailDomain: emailDomain,
|
||||
}
|
||||
|
||||
return token, nil
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens DROP COLUMN email_domain;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,5 @@
|
||||
PRAGMA foreign_keys=OFF;
|
||||
BEGIN;
|
||||
ALTER TABLE signup_tokens ADD COLUMN email_domain TEXT;
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -19,7 +19,7 @@
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "6.6.6",
|
||||
"react-email": "6.7.0",
|
||||
"tsx": "^4.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,6 +406,11 @@
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"email_domain": "Email Domain",
|
||||
"signup_token_email_domain_description": "Optionally restrict signups with this token to email addresses of a specific domain (e.g. example.com). Leave empty to allow any domain.",
|
||||
"invalid_email_domain": "Enter a valid domain, e.g. example.com",
|
||||
"email_must_use_domain": "The email address must use the domain {domain}",
|
||||
"email_domain_required_hint": "Must be an email address with the domain {domain}",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"user_creation": "User Creation",
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
|
||||
let {
|
||||
callback,
|
||||
isLoading
|
||||
isLoading,
|
||||
requiredEmailDomain
|
||||
}: {
|
||||
callback: (user: UserSignUp) => Promise<boolean>;
|
||||
isLoading: boolean;
|
||||
requiredEmailDomain?: string | null;
|
||||
} = $props();
|
||||
|
||||
const initialData: UserSignUp = {
|
||||
@@ -25,11 +27,19 @@
|
||||
username: ''
|
||||
};
|
||||
|
||||
const emailSchema = requiredEmailDomain
|
||||
? z.email().refine((v) => v.toLowerCase().endsWith(`@${requiredEmailDomain.toLowerCase()}`), {
|
||||
message: m.email_must_use_domain({ domain: `@${requiredEmailDomain}` })
|
||||
})
|
||||
: get(appConfigStore).requireUserEmail
|
||||
? z.email()
|
||||
: emptyToUndefined(z.email().optional());
|
||||
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().max(50),
|
||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||
username: usernameSchema,
|
||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||
email: emailSchema
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
@@ -53,7 +63,15 @@
|
||||
<form id="sign-up-form" onsubmit={preventDefault(onSubmit)} class="w-full">
|
||||
<div class="mt-7 space-y-4">
|
||||
<FormInput label={m.username()} bind:input={$inputs.username} />
|
||||
<FormInput label={m.email()} bind:input={$inputs.email} type="email" />
|
||||
<FormInput
|
||||
label={m.email()}
|
||||
bind:input={$inputs.email}
|
||||
type="email"
|
||||
placeholder={requiredEmailDomain ? `you@${requiredEmailDomain}` : undefined}
|
||||
description={requiredEmailDomain
|
||||
? m.email_domain_required_hint({ domain: `@${requiredEmailDomain}` })
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
|
||||
|
||||
@@ -106,6 +106,11 @@
|
||||
sortable: true,
|
||||
value: (item) => formatDate(item.expiresAt)
|
||||
},
|
||||
{
|
||||
key: 'emailDomain',
|
||||
label: m.email_domain(),
|
||||
value: (item) => (item.emailDomain ? `@${item.emailDomain}` : '—')
|
||||
},
|
||||
{
|
||||
key: 'userGroups',
|
||||
label: m.user_groups(),
|
||||
|
||||
@@ -44,18 +44,28 @@
|
||||
ttl: number;
|
||||
usageLimit: number;
|
||||
userGroupIds: string[];
|
||||
emailDomain: string;
|
||||
};
|
||||
|
||||
const initialFormValues: SignupTokenForm = {
|
||||
ttl: defaultExpiration,
|
||||
usageLimit: 1,
|
||||
userGroupIds: []
|
||||
userGroupIds: [],
|
||||
emailDomain: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
ttl: z.number(),
|
||||
usageLimit: z.number().min(1).max(100),
|
||||
userGroupIds: z.array(z.string()).default([])
|
||||
userGroupIds: z.array(z.string()).default([]),
|
||||
emailDomain: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v) => v.replace(/^@/, ''))
|
||||
.refine((v) => v === '' || /^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/i.test(v), {
|
||||
message: m.invalid_email_domain()
|
||||
})
|
||||
.default('')
|
||||
});
|
||||
|
||||
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, initialFormValues);
|
||||
@@ -85,7 +95,8 @@
|
||||
signupToken = await userService.createSignupToken(
|
||||
data.ttl,
|
||||
data.usageLimit,
|
||||
data.userGroupIds
|
||||
data.userGroupIds,
|
||||
data.emailDomain
|
||||
);
|
||||
signupLink = `${page.url.origin}/st/${signupToken}`;
|
||||
createdSignupData = data;
|
||||
@@ -165,6 +176,21 @@
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="email-domain"
|
||||
label={m.email_domain()}
|
||||
description={m.signup_token_email_domain_description()}
|
||||
input={$inputs.emailDomain}
|
||||
>
|
||||
<Input
|
||||
id="email-domain"
|
||||
type="text"
|
||||
placeholder="example.com"
|
||||
bind:value={$inputs.emailDomain.value}
|
||||
aria-invalid={$inputs.emailDomain.error ? 'true' : undefined}
|
||||
class="h-9"
|
||||
/>
|
||||
</FormInput>
|
||||
<FormInput
|
||||
labelFor="default-groups"
|
||||
label={m.user_groups()}
|
||||
@@ -197,6 +223,9 @@
|
||||
<div class="text-muted-foreground mt-2 text-center text-sm">
|
||||
<p>{m.usage_limit()}: {createdSignupData?.usageLimit}</p>
|
||||
<p>{m.expiration()}: {getExpirationLabel(createdSignupData?.ttl ?? 0)}</p>
|
||||
{#if createdSignupData?.emailDomain}
|
||||
<p>{m.email_domain()}: @{createdSignupData.emailDomain}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import userStore from '$lib/stores/user-store';
|
||||
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
|
||||
import type { Passkey } from '$lib/types/passkey.type';
|
||||
import type { SignupToken } from '$lib/types/signup-token.type';
|
||||
import type { SignupToken, SignupTokenInfo } from '$lib/types/signup-token.type';
|
||||
import type { UserGroup } from '$lib/types/user-group.type';
|
||||
import type { AccountUpdate, User, UserCreate, UserSignUp } from '$lib/types/user.type';
|
||||
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
|
||||
@@ -89,12 +89,23 @@ export default class UserService extends APIService {
|
||||
createSignupToken = async (
|
||||
ttl: string | number,
|
||||
usageLimit: number,
|
||||
userGroupIds: string[] = []
|
||||
userGroupIds: string[] = [],
|
||||
emailDomain?: string | null
|
||||
) => {
|
||||
const res = await this.api.post(`/signup-tokens`, { ttl, usageLimit, userGroupIds });
|
||||
const res = await this.api.post(`/signup-tokens`, {
|
||||
ttl,
|
||||
usageLimit,
|
||||
userGroupIds,
|
||||
emailDomain: emailDomain || undefined
|
||||
});
|
||||
return res.data.token;
|
||||
};
|
||||
|
||||
getSignupTokenInfo = async (token: string) => {
|
||||
const res = await this.api.get(`/signup/token/${token}`);
|
||||
return res.data as SignupTokenInfo;
|
||||
};
|
||||
|
||||
exchangeOneTimeAccessToken = async (token: string) => {
|
||||
const res = await this.api.post(`/one-time-access-token/${token}`);
|
||||
return res.data as User;
|
||||
|
||||
@@ -6,6 +6,11 @@ export interface SignupToken {
|
||||
expiresAt: string;
|
||||
usageLimit: number;
|
||||
usageCount: number;
|
||||
emailDomain?: string | null;
|
||||
userGroups: UserGroup[];
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface SignupTokenInfo {
|
||||
emailDomain?: string | null;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,11 @@
|
||||
</p>
|
||||
{/if}
|
||||
{#if $appConfigStore.allowUserSignups === 'open' || data.token}
|
||||
<SignupForm callback={handleSignup} {isLoading} />
|
||||
<SignupForm
|
||||
callback={handleSignup}
|
||||
{isLoading}
|
||||
requiredEmailDomain={data.requiredEmailDomain}
|
||||
/>
|
||||
<div class="mt-10 flex w-full items-center justify-between gap-2">
|
||||
<a class="text-muted-foreground mt-5 flex text-sm" href="/login"
|
||||
><LucideChevronLeft class="size-5" /> {m.back()}</a
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import UserService from '$lib/services/user-service';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
const token = url.searchParams.get('token') || undefined;
|
||||
|
||||
let requiredEmailDomain: string | null = null;
|
||||
if (token) {
|
||||
// Best-effort lookup of the token's public metadata to hint the required email domain.
|
||||
// Failures (e.g. an invalid or expired token) are ignored here; the signup request itself enforces validity.
|
||||
const userService = new UserService();
|
||||
try {
|
||||
const info = await userService.getSignupTokenInfo(token);
|
||||
requiredEmailDomain = info.emailDomain ?? null;
|
||||
} catch {
|
||||
requiredEmailDomain = null;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: url.searchParams.get('token') || undefined
|
||||
token,
|
||||
requiredEmailDomain
|
||||
};
|
||||
};
|
||||
|
||||
682
pnpm-lock.yaml
generated
682
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -192,5 +192,14 @@ export const signupTokens = {
|
||||
usageLimit: 1,
|
||||
usageCount: 1,
|
||||
createdAt: new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString()
|
||||
},
|
||||
domainRestricted: {
|
||||
id: 'b2c3d4e5-f6a7-4890-bcde-f01234567891',
|
||||
token: 'DOMAIN1234567890',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
usageLimit: 5,
|
||||
usageCount: 0,
|
||||
emailDomain: 'example.com',
|
||||
createdAt: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"provider": "sqlite",
|
||||
"version": 20260708130000,
|
||||
"version": 20260727000000,
|
||||
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens", "apis", "api_permissions", "oidc_clients_allowed_api_permissions"],
|
||||
"tables": {
|
||||
"apis": [
|
||||
@@ -271,7 +271,8 @@
|
||||
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"token": "VALID1234567890A",
|
||||
"usage_count": 0,
|
||||
"usage_limit": 1
|
||||
"usage_limit": 1,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -279,7 +280,8 @@
|
||||
"id": "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
"token": "PARTIAL567890ABC",
|
||||
"usage_count": 2,
|
||||
"usage_limit": 5
|
||||
"usage_limit": 5,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -287,7 +289,8 @@
|
||||
"id": "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||
"token": "EXPIRED34567890B",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 3
|
||||
"usage_limit": 3,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
@@ -295,7 +298,17 @@
|
||||
"id": "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
"token": "FULLYUSED567890C",
|
||||
"usage_count": 1,
|
||||
"usage_limit": 1
|
||||
"usage_limit": 1,
|
||||
"email_domain": null
|
||||
},
|
||||
{
|
||||
"created_at": "2025-11-25T12:39:02Z",
|
||||
"expires_at": "2025-11-26T12:39:02Z",
|
||||
"id": "b2c3d4e5-f6a7-4890-bcde-f01234567891",
|
||||
"token": "DOMAIN1234567890",
|
||||
"usage_count": 0,
|
||||
"usage_limit": 5,
|
||||
"email_domain": "example.com"
|
||||
}
|
||||
],
|
||||
"signup_tokens_user_groups": [
|
||||
|
||||
@@ -62,6 +62,20 @@ test.describe('Signup Token Creation', () => {
|
||||
await expect(row.getByRole('cell', { name: userGroups.developers.name })).toBeVisible();
|
||||
await expect(row.getByRole('cell', { name: userGroups.designers.name })).toBeVisible();
|
||||
});
|
||||
|
||||
test('Create signup token with email domain restriction', async ({ page }) => {
|
||||
await page.goto('/settings/admin/users');
|
||||
|
||||
await page.getByRole('button', { name: 'Create options' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Create Signup Token' }).click();
|
||||
|
||||
await page.getByLabel('Email Domain').fill('@example.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
|
||||
// The success view shows the configured domain restriction
|
||||
await expect(page.getByText('@example.com').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Initial User Signup', () => {
|
||||
@@ -241,6 +255,69 @@ test.describe('User Signup', () => {
|
||||
await expect(page.getByText('Passkey missing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - email domain hint and placeholder are shown', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await expect(
|
||||
page.getByText('Must be an email address with the domain @example.com')
|
||||
).toBeVisible();
|
||||
await expect(page.getByPlaceholder('you@example.com')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - wrong email domain shows validation error', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await page.getByLabel('First name').fill('Domain');
|
||||
await page.getByLabel('Last name').fill('User');
|
||||
await page.getByLabel('Username').fill('domainuser');
|
||||
await page.getByLabel('Email').fill('domain.user@wrong.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText('The email address must use the domain @example.com')
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - matching email domain succeeds', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
await page.goto(`/st/${signupTokens.domainRestricted.token}`);
|
||||
|
||||
await page.getByLabel('First name').fill('Domain');
|
||||
await page.getByLabel('Last name').fill('User');
|
||||
await page.getByLabel('Username').fill('domainuser');
|
||||
await page.getByLabel('Email').fill('domain.user@example.com');
|
||||
|
||||
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||
|
||||
await page.waitForURL('/signup/add-passkey');
|
||||
await expect(page.getByText('Set up your passkey')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Signup with token - server enforces email domain restriction', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
// Bypass the client-side validation to verify the domain is enforced server-side
|
||||
const res = await page.request.post('/api/signup', {
|
||||
data: {
|
||||
username: 'apidomainuser',
|
||||
email: 'api.user@wrong.com',
|
||||
firstName: 'Api',
|
||||
lastName: 'User',
|
||||
token: signupTokens.domainRestricted.token
|
||||
}
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(400);
|
||||
const body = await res.json();
|
||||
expect(body.error).toContain('example.com');
|
||||
});
|
||||
|
||||
test('Token usage limit is enforced', async ({ page }) => {
|
||||
await setSignupMode(page, 'Signup with token');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user