mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 07:12:59 +03:00
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2dfb3da5d | ||
|
|
cbf0e3117d | ||
|
|
694f266dea | ||
|
|
29fc185376 | ||
|
|
781be37416 | ||
|
|
b1f97e05a1 | ||
|
|
2c74865173 | ||
|
|
ad8a90c839 | ||
|
|
f9839a978c | ||
|
|
b81de45166 | ||
|
|
22f4254932 | ||
|
|
507f9490fa | ||
|
|
043cce615d | ||
|
|
69e2083722 | ||
|
|
d47b20326f | ||
|
|
fc9939d1f1 | ||
|
|
2c1c67b5e4 | ||
|
|
d010be4c88 | ||
|
|
01db8c0a46 | ||
|
|
fe5917d96d | ||
|
|
4f0b434c54 | ||
|
|
6bdf5fa37a | ||
|
|
47bd5ba1ba | ||
|
|
b746ac0835 | ||
|
|
79989fb176 | ||
|
|
ecc7e224e9 | ||
|
|
549d219f44 | ||
|
|
ffe18db2fb | ||
|
|
e8b172f1c3 |
4
.github/workflows/backend-linter.yml
vendored
4
.github/workflows/backend-linter.yml
vendored
@@ -24,10 +24,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
|
||||
|
||||
18
.github/workflows/build-next.yml
vendored
18
.github/workflows/build-next.yml
vendored
@@ -19,22 +19,20 @@ jobs:
|
||||
attestations: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'backend/go.mod'
|
||||
go-version-file: "backend/go.mod"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -74,7 +72,7 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
||||
file: Dockerfile-prebuilt
|
||||
file: docker/Dockerfile-prebuilt
|
||||
- name: Build and push container image (distroless)
|
||||
uses: docker/build-push-action@v6
|
||||
id: container-build-push-distroless
|
||||
@@ -83,16 +81,16 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
|
||||
file: Dockerfile-distroless
|
||||
file: docker/Dockerfile-distroless
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Container image attestation (distroless)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
23
.github/workflows/e2e-tests.yml
vendored
23
.github/workflows/e2e-tests.yml
vendored
@@ -3,15 +3,15 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.github/**'
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.github/**'
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -30,6 +30,8 @@ jobs:
|
||||
- name: Build and export
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: false
|
||||
load: false
|
||||
tags: pocket-id:test
|
||||
@@ -57,16 +59,15 @@ jobs:
|
||||
matrix:
|
||||
db: [sqlite, postgres]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v3
|
||||
|
||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
- "v*.*.*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -19,14 +19,12 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'backend/go.mod'
|
||||
go-version-file: "backend/go.mod"
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
@@ -81,7 +79,7 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: Dockerfile-prebuilt
|
||||
file: docker/Dockerfile-prebuilt
|
||||
- name: Build and push container image (distroless)
|
||||
uses: docker/build-push-action@v6
|
||||
id: container-build-push-distroless
|
||||
@@ -91,21 +89,21 @@ jobs:
|
||||
push: true
|
||||
tags: ${{ steps.meta-distroless.outputs.tags }}
|
||||
labels: ${{ steps.meta-distroless.outputs.labels }}
|
||||
file: Dockerfile-distroless
|
||||
file: docker/Dockerfile-distroless
|
||||
- name: Binary attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: 'backend/.bin/pocket-id-**'
|
||||
subject-path: "backend/.bin/pocket-id-**"
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Container image attestation (distroless)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Upload binaries to release
|
||||
@@ -122,6 +120,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
- name: Mark release as published
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
|
||||
30
.github/workflows/svelte-check.yml
vendored
30
.github/workflows/svelte-check.yml
vendored
@@ -4,21 +4,21 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -34,17 +34,15 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
4
.github/workflows/unit-tests.yml
vendored
4
.github/workflows/unit-tests.yml
vendored
@@ -16,8 +16,8 @@ jobs:
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "backend/go.mod"
|
||||
cache-dependency-path: "backend/go.sum"
|
||||
|
||||
2
.github/workflows/update-aaguids.yml
vendored
2
.github/workflows/update-aaguids.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Fetch JSON data
|
||||
run: |
|
||||
|
||||
860
CHANGELOG.md
860
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -140,6 +141,7 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
// Choose the correct database provider
|
||||
var onConnFn func(conn *sql.DB)
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
@@ -148,7 +150,7 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
|
||||
sqliteutil.RegisterSqliteFunctions()
|
||||
|
||||
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||
connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -159,6 +161,14 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if isMemoryDB {
|
||||
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
|
||||
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
|
||||
onConnFn = func(conn *sql.DB) {
|
||||
conn.SetMaxOpenConns(1)
|
||||
}
|
||||
}
|
||||
|
||||
dialector = sqlite.Open(connString)
|
||||
case common.DbProviderPostgres:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
@@ -176,6 +186,16 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
})
|
||||
if err == nil {
|
||||
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||
|
||||
if onConnFn != nil {
|
||||
conn, err := db.DB()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
onConnFn(conn)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
@@ -188,18 +208,18 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
|
||||
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
|
||||
if !strings.HasPrefix(connString, "file:") {
|
||||
connString = "file:" + connString
|
||||
}
|
||||
|
||||
// Check if we're using an in-memory database
|
||||
isMemoryDB := isSqliteInMemory(connString)
|
||||
isMemoryDB = isSqliteInMemory(connString)
|
||||
|
||||
// Parse the connection string
|
||||
connStringUrl, err := url.Parse(connString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||
return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Convert options for the old SQLite driver to the new one
|
||||
@@ -208,7 +228,7 @@ func parseSqliteConnectionString(connString string) (parsedConnString string, db
|
||||
// Add the default and required params
|
||||
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||
return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Get the absolute path to the database
|
||||
@@ -217,10 +237,10 @@ func parseSqliteConnectionString(connString string) (parsedConnString string, db
|
||||
idx := strings.IndexRune(parsedConnString, '?')
|
||||
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||
return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||
}
|
||||
|
||||
return parsedConnString, dbPath, nil
|
||||
return parsedConnString, dbPath, isMemoryDB, nil
|
||||
}
|
||||
|
||||
// The official C implementation of SQLite allows some additional properties in the connection string
|
||||
@@ -272,11 +292,6 @@ func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
|
||||
qs = make(url.Values, 2)
|
||||
}
|
||||
|
||||
// If the database is in-memory, we must ensure that cache=shared is set
|
||||
if isMemoryDB {
|
||||
qs["cache"] = []string{"shared"}
|
||||
}
|
||||
|
||||
// Check if the database is read-only or immutable
|
||||
isReadOnly := false
|
||||
if len(qs["mode"]) > 0 {
|
||||
|
||||
@@ -205,7 +205,7 @@ func TestAddSqliteDefaultParameters(t *testing.T) {
|
||||
name: "in-memory database",
|
||||
input: "file::memory:",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "read-only database with mode=ro",
|
||||
@@ -249,12 +249,6 @@ func TestAddSqliteDefaultParameters(t *testing.T) {
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "in-memory database with cache already set",
|
||||
input: "file::memory:?cache=private",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
},
|
||||
{
|
||||
name: "database with mode=rw (not read-only)",
|
||||
input: "file:test.db?mode=rw",
|
||||
|
||||
@@ -119,6 +119,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
if common.EnvConfig.UnixSocket != "" {
|
||||
network = "unix"
|
||||
addr = common.EnvConfig.UnixSocket
|
||||
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||
|
||||
@@ -56,7 +56,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
@@ -180,6 +181,25 @@ func validateEnvConfig(config *EnvConfigSchema) error {
|
||||
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
|
||||
}
|
||||
|
||||
// Validate LOCAL_IPV6_RANGES
|
||||
ranges := strings.Split(config.LocalIPv6Ranges, ",")
|
||||
for _, rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() != nil {
|
||||
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
@@ -378,3 +378,13 @@ func (e *ClientIdAlreadyExistsError) Error() string {
|
||||
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type UserEmailNotSetError struct{}
|
||||
|
||||
func (e *UserEmailNotSetError) Error() string {
|
||||
return "The user does not have an email address set"
|
||||
}
|
||||
|
||||
func (e *UserEmailNotSetError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ type AppConfigUpdateDto struct {
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
|
||||
@@ -38,6 +38,8 @@ type OidcClientUpdateDto struct {
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
LogoURL *string `json:"logoUrl"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
Email *string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
@@ -24,10 +24,10 @@ type UserDto struct {
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
@@ -64,9 +64,9 @@ type UserUpdateUserGroupDto struct {
|
||||
}
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -16,7 +17,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "valid input",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -26,7 +27,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
{
|
||||
name: "missing username",
|
||||
input: UserCreateDto{
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -36,7 +37,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
{
|
||||
name: "missing display name",
|
||||
input: UserCreateDto{
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
},
|
||||
@@ -46,7 +47,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "username contains invalid characters",
|
||||
input: UserCreateDto{
|
||||
Username: "test/ser",
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -57,7 +58,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "invalid email",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "not-an-email",
|
||||
Email: utils.Ptr("not-an-email"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -68,7 +69,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "first name too short",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -79,7 +80,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "last name too long",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
DisplayName: "John Doe",
|
||||
|
||||
@@ -67,14 +67,12 @@ func ValidateClientID(clientID string) bool {
|
||||
|
||||
// ValidateCallbackURL validates callback URLs with support for wildcards
|
||||
func ValidateCallbackURL(raw string) bool {
|
||||
if raw == "*" {
|
||||
// Don't validate if it contains a wildcard
|
||||
if strings.Contains(raw, "*") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Replace all '*' with 'x' to check if the rest is still a valid URI
|
||||
test := strings.ReplaceAll(raw, "*", "x")
|
||||
|
||||
u, err := url.Parse(test)
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
||||
}
|
||||
|
||||
for _, key := range apiKeys {
|
||||
if key.User.Email == "" {
|
||||
if key.User.Email == nil {
|
||||
continue
|
||||
}
|
||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||
|
||||
@@ -34,7 +34,7 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
|
||||
@@ -46,6 +46,7 @@ type AppConfig struct {
|
||||
// Internal
|
||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||
// Email
|
||||
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
|
||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -54,7 +52,6 @@ type OidcClient struct {
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
RequiresReauthentication bool
|
||||
@@ -67,6 +64,10 @@ type OidcClient struct {
|
||||
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
|
||||
}
|
||||
|
||||
func (c OidcClient) HasLogo() bool {
|
||||
return c.ImageType != nil && *c.ImageType != ""
|
||||
}
|
||||
|
||||
type OidcRefreshToken struct {
|
||||
Base
|
||||
|
||||
@@ -89,12 +90,6 @@ func (c OidcRefreshToken) Scopes() []string {
|
||||
return strings.Split(c.Scope, " ")
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
// Compute HasLogo field
|
||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
type OidcClientCredentials struct { //nolint:recvcheck
|
||||
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ import (
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `sortable:"true"`
|
||||
Email string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
DisplayName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true"`
|
||||
Username string `sortable:"true"`
|
||||
Email *string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
DisplayName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true"`
|
||||
Locale *string
|
||||
LdapID *string
|
||||
Disabled bool `sortable:"true"`
|
||||
|
||||
@@ -144,9 +144,13 @@ func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey
|
||||
}
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
err := SendEmail(ctx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: user.Email,
|
||||
Email: *user.Email,
|
||||
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
||||
ApiKeyName: apiKey.Name,
|
||||
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
||||
|
||||
@@ -71,6 +71,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
// Internal
|
||||
InstanceID: model.AppConfigVariable{Value: ""},
|
||||
// Email
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "true"},
|
||||
SmtpHost: model.AppConfigVariable{},
|
||||
SmtpPort: model.AppConfigVariable{},
|
||||
SmtpFrom: model.AppConfigVariable{},
|
||||
|
||||
@@ -111,9 +111,13 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
return
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return
|
||||
}
|
||||
|
||||
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: user.Email,
|
||||
Email: *user.Email,
|
||||
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||
IPAddress: ipAddress,
|
||||
Country: createdAuditLog.Country,
|
||||
@@ -122,7 +126,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||
})
|
||||
if innerErr != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email))
|
||||
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
},
|
||||
Username: "tim",
|
||||
Email: "tim.cook@test.com",
|
||||
Email: utils.Ptr("tim.cook@test.com"),
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
@@ -90,7 +90,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
},
|
||||
Username: "craig",
|
||||
Email: "craig.federighi@test.com",
|
||||
Email: utils.Ptr("craig.federighi@test.com"),
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
DisplayName: "Craig Federighi",
|
||||
|
||||
@@ -62,9 +62,13 @@ func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId stri
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
return SendEmail(ctx, srv,
|
||||
email.Address{
|
||||
Email: user.Email,
|
||||
Email: *user.Email,
|
||||
Name: user.FullName(),
|
||||
}, TestTemplate, nil)
|
||||
}
|
||||
|
||||
@@ -13,35 +13,19 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/oschwald/maxminddb-golang/v2"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type GeoLiteService struct {
|
||||
httpClient *http.Client
|
||||
disableUpdater bool
|
||||
mutex sync.RWMutex
|
||||
localIPv6Ranges []*net.IPNet
|
||||
}
|
||||
|
||||
var localhostIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
|
||||
}
|
||||
|
||||
var privateLanIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||
}
|
||||
|
||||
var tailscaleIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
|
||||
httpClient *http.Client
|
||||
disableUpdater bool
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
|
||||
@@ -56,67 +40,9 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
|
||||
service.disableUpdater = true
|
||||
}
|
||||
|
||||
// Initialize IPv6 local ranges
|
||||
err := service.initializeIPv6LocalRanges()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable
|
||||
func (s *GeoLiteService) initializeIPv6LocalRanges() error {
|
||||
rangesEnv := common.EnvConfig.LocalIPv6Ranges
|
||||
if rangesEnv == "" {
|
||||
return nil // No local IPv6 ranges configured
|
||||
}
|
||||
|
||||
ranges := strings.Split(rangesEnv, ",")
|
||||
localRanges := make([]*net.IPNet, 0, len(ranges))
|
||||
|
||||
for _, rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err)
|
||||
}
|
||||
|
||||
// Ensure it's an IPv6 range
|
||||
if ipNet.IP.To4() != nil {
|
||||
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||
}
|
||||
|
||||
localRanges = append(localRanges, ipNet)
|
||||
}
|
||||
|
||||
s.localIPv6Ranges = localRanges
|
||||
|
||||
if len(localRanges) > 0 {
|
||||
slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges
|
||||
func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool {
|
||||
if ip.To4() != nil {
|
||||
return false // Not an IPv6 address
|
||||
}
|
||||
|
||||
for _, localRange := range s.localIPv6Ranges {
|
||||
if localRange.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *GeoLiteService) DisableUpdater() bool {
|
||||
return s.disableUpdater
|
||||
}
|
||||
@@ -129,26 +55,17 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
||||
|
||||
// Check the IP address against known private IP ranges
|
||||
if ip := net.ParseIP(ipAddress); ip != nil {
|
||||
// Check IPv6 local ranges first
|
||||
if s.isLocalIPv6(ip) {
|
||||
if utils.IsLocalIPv6(ip) {
|
||||
return "Internal Network", "LAN", nil
|
||||
}
|
||||
|
||||
// Check existing IPv4 ranges
|
||||
for _, ipNet := range tailscaleIPNets {
|
||||
if ipNet.Contains(ip) {
|
||||
return "Internal Network", "Tailscale", nil
|
||||
}
|
||||
if utils.IsTailscaleIP(ip) {
|
||||
return "Internal Network", "Tailscale", nil
|
||||
}
|
||||
for _, ipNet := range privateLanIPNets {
|
||||
if ipNet.Contains(ip) {
|
||||
return "Internal Network", "LAN", nil
|
||||
}
|
||||
if utils.IsPrivateIP(ip) {
|
||||
return "Internal Network", "LAN", nil
|
||||
}
|
||||
for _, ipNet := range localhostIPNets {
|
||||
if ipNet.Contains(ip) {
|
||||
return "Internal Network", "localhost", nil
|
||||
}
|
||||
if utils.IsLocalhostIP(ip) {
|
||||
return "Internal Network", "localhost", nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
localRanges string
|
||||
testIP string
|
||||
expectedCountry string
|
||||
expectedCity string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "IPv6 in local range",
|
||||
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||
testIP: "2001:0db8:abcd:000::1",
|
||||
expectedCountry: "Internal Network",
|
||||
expectedCity: "LAN",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv6 not in local range",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "2001:0db8:ffff:000::1",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "Multiple ranges - second range match",
|
||||
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||
testIP: "2001:0db8:abcd:001::1",
|
||||
expectedCountry: "Internal Network",
|
||||
expectedCity: "LAN",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Empty local ranges",
|
||||
localRanges: "",
|
||||
testIP: "2001:0db8:abcd:000::1",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "IPv4 private address still works",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "192.168.1.1",
|
||||
expectedCountry: "Internal Network",
|
||||
expectedCity: "LAN",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "::1",
|
||||
expectedCountry: "Internal Network",
|
||||
expectedCity: "localhost",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||
defer func() {
|
||||
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||
}()
|
||||
|
||||
service := NewGeoLiteService(&http.Client{})
|
||||
|
||||
country, city, err := service.GetLocationByIP(tt.testIP)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil && country != "Internal Network" {
|
||||
t.Errorf("Expected error or internal network classification for external IP")
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedCountry, country)
|
||||
assert.Equal(t, tt.expectedCity, city)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoLiteService_isLocalIPv6(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
localRanges string
|
||||
testIP string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Valid IPv6 in range",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "2001:0db8:abcd:000::1",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Valid IPv6 not in range",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "2001:0db8:ffff:000::1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "IPv4 address should return false",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "192.168.1.1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "No ranges configured",
|
||||
localRanges: "",
|
||||
testIP: "2001:0db8:abcd:000::1",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Edge of range",
|
||||
localRanges: "2001:0db8:abcd:000::/56",
|
||||
testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||
defer func() {
|
||||
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||
}()
|
||||
|
||||
service := NewGeoLiteService(&http.Client{})
|
||||
ip := net.ParseIP(tt.testIP)
|
||||
if ip == nil {
|
||||
t.Fatalf("Invalid test IP: %s", tt.testIP)
|
||||
}
|
||||
|
||||
result := service.isLocalIPv6(ip)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envValue string
|
||||
expectError bool
|
||||
expectCount int
|
||||
}{
|
||||
{
|
||||
name: "Valid IPv6 ranges",
|
||||
envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||
expectError: false,
|
||||
expectCount: 2,
|
||||
},
|
||||
{
|
||||
name: "Empty environment variable",
|
||||
envValue: "",
|
||||
expectError: false,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Invalid CIDR notation",
|
||||
envValue: "2001:0db8:abcd:000::/999",
|
||||
expectError: true,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "IPv4 range in IPv6 env var",
|
||||
envValue: "192.168.1.0/24",
|
||||
expectError: true,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Mixed valid and invalid ranges",
|
||||
envValue: "2001:0db8:abcd:000::/56,invalid-range",
|
||||
expectError: true,
|
||||
expectCount: 0,
|
||||
},
|
||||
{
|
||||
name: "Whitespace handling",
|
||||
envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ",
|
||||
expectError: false,
|
||||
expectCount: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||
common.EnvConfig.LocalIPv6Ranges = tt.envValue
|
||||
defer func() {
|
||||
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||
}()
|
||||
|
||||
service := &GeoLiteService{
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
|
||||
err := service.initializeIPv6LocalRanges()
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Len(t, service.localIPv6Ranges, tt.expectCount)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -342,7 +343,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "user123",
|
||||
},
|
||||
Email: "user@example.com",
|
||||
Email: utils.Ptr("user@example.com"),
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
@@ -385,7 +386,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "admin123",
|
||||
},
|
||||
Email: "admin@example.com",
|
||||
Email: utils.Ptr("admin@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -464,7 +465,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "eddsauser123",
|
||||
},
|
||||
Email: "eddsauser@example.com",
|
||||
Email: utils.Ptr("eddsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -521,7 +522,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "ecdsauser123",
|
||||
},
|
||||
Email: "ecdsauser@example.com",
|
||||
Email: utils.Ptr("ecdsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -578,7 +579,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "rsauser123",
|
||||
},
|
||||
Email: "rsauser@example.com",
|
||||
Email: utils.Ptr("rsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -965,7 +966,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "user123",
|
||||
},
|
||||
Email: "user@example.com",
|
||||
Email: utils.Ptr("user@example.com"),
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
@@ -1092,7 +1093,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "eddsauser789",
|
||||
},
|
||||
Email: "eddsaoauth@example.com",
|
||||
Email: utils.Ptr("eddsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "eddsa-oauth-client"
|
||||
|
||||
@@ -1149,7 +1150,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "ecdsauser789",
|
||||
},
|
||||
Email: "ecdsaoauth@example.com",
|
||||
Email: utils.Ptr("ecdsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "ecdsa-oauth-client"
|
||||
|
||||
@@ -1206,7 +1207,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
Base: model.Base{
|
||||
ID: "rsauser789",
|
||||
},
|
||||
Email: "rsaoauth@example.com",
|
||||
Email: utils.Ptr("rsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "rsa-oauth-client"
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -348,13 +349,18 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
|
||||
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
}
|
||||
|
||||
if newUser.DisplayName == "" {
|
||||
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
||||
}
|
||||
|
||||
dto.Normalize(newUser)
|
||||
|
||||
err = newUser.Validate()
|
||||
|
||||
@@ -8,10 +8,14 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -66,6 +70,7 @@ func NewOidcService(
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
webAuthnService *WebAuthnService,
|
||||
httpClient *http.Client,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
@@ -74,6 +79,7 @@ func NewOidcService(
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
webAuthnService: webAuthnService,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
|
||||
@@ -469,7 +475,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
var storedRefreshToken model.OidcRefreshToken
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("User").
|
||||
Preload("User.UserGroups").
|
||||
Where(
|
||||
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
|
||||
utils.CreateSha256Hash(rt),
|
||||
@@ -714,6 +720,11 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
|
||||
}
|
||||
|
||||
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
client := model.OidcClient{
|
||||
Base: model.Base{
|
||||
ID: input.ID,
|
||||
@@ -722,7 +733,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
||||
}
|
||||
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||
|
||||
err := s.db.
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Create(&client).
|
||||
Error
|
||||
@@ -733,33 +744,11 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var client model.OidcClient
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("CreatedBy").
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
updateOIDCClientModelFromDto(&client, &input)
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Save(&client).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.OidcClient{}, err
|
||||
if input.LogoURL != nil {
|
||||
err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
@@ -770,6 +759,36 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() { tx.Rollback() }()
|
||||
|
||||
var client model.OidcClient
|
||||
if err := tx.WithContext(ctx).
|
||||
Preload("CreatedBy").
|
||||
First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
updateOIDCClientModelFromDto(&client, &input)
|
||||
|
||||
if err := tx.WithContext(ctx).Save(&client).Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
if input.LogoURL != nil {
|
||||
err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL)
|
||||
if err != nil {
|
||||
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
|
||||
// Base fields
|
||||
client.Name = input.Name
|
||||
@@ -883,41 +902,14 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
|
||||
err = s.updateClientLogoType(ctx, tx, clientID, fileType)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var client model.OidcClient
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
First(&client, "id = ?", clientID).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if client.ImageType != nil && fileType != *client.ImageType {
|
||||
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
|
||||
if err := os.Remove(oldImagePath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
client.ImageType = &fileType
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Save(&client).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
|
||||
@@ -941,6 +933,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
|
||||
|
||||
oldImageType := *client.ImageType
|
||||
client.ImageType = nil
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Save(&client).
|
||||
@@ -1333,7 +1326,7 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us
|
||||
Client: dto.OidcClientMetaDataDto{
|
||||
ID: deviceAuth.Client.ID,
|
||||
Name: deviceAuth.Client.Name,
|
||||
HasLogo: deviceAuth.Client.HasLogo,
|
||||
HasLogo: deviceAuth.Client.HasLogo(),
|
||||
},
|
||||
Scope: deviceAuth.Scope,
|
||||
AuthorizationRequired: !hasAuthorizedClient,
|
||||
@@ -1468,7 +1461,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
ID: client.ID,
|
||||
Name: client.Name,
|
||||
LaunchURL: client.LaunchURL,
|
||||
HasLogo: client.HasLogo,
|
||||
HasLogo: client.HasLogo(),
|
||||
},
|
||||
LastUsedAt: lastUsedAt,
|
||||
}
|
||||
@@ -1889,3 +1882,93 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
|
||||
|
||||
return s.IsUserGroupAllowedToAuthorize(user, client), nil
|
||||
}
|
||||
|
||||
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *gorm.DB, clientID string, raw string) error {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
r := net.Resolver{}
|
||||
ips, err := r.LookupIPAddr(ctx, u.Hostname())
|
||||
if err != nil || len(ips) == 0 {
|
||||
return fmt.Errorf("cannot resolve hostname")
|
||||
}
|
||||
|
||||
// Prevents SSRF by allowing only public IPs
|
||||
for _, addr := range ips {
|
||||
if utils.IsPrivateIP(addr.IP) {
|
||||
return fmt.Errorf("private IP addresses are not allowed")
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
|
||||
req.Header.Set("Accept", "image/*")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("failed to fetch logo: %s", resp.Status)
|
||||
}
|
||||
|
||||
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
|
||||
if resp.ContentLength > maxLogoSize {
|
||||
return fmt.Errorf("logo is too large")
|
||||
}
|
||||
|
||||
// Prefer extension in path if supported
|
||||
ext := utils.GetFileExtension(u.Path)
|
||||
if ext == "" || utils.GetImageMimeType(ext) == "" {
|
||||
// Otherwise, try to detect from content type
|
||||
ext = utils.GetImageExtensionFromMimeType(resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
if ext == "" {
|
||||
return &common.FileTypeNotSupportedError{}
|
||||
}
|
||||
|
||||
folderPath := filepath.Join(common.EnvConfig.UploadPath, "oidc-client-images")
|
||||
err = os.MkdirAll(folderPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
imagePath := filepath.Join(folderPath, clientID+"."+ext)
|
||||
err = utils.SaveFileStream(io.LimitReader(resp.Body, maxLogoSize+1), imagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.updateClientLogoType(ctx, tx, clientID, ext); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) updateClientLogoType(ctx context.Context, tx *gorm.DB, clientID, ext string) error {
|
||||
uploadsDir := common.EnvConfig.UploadPath + "/oidc-client-images"
|
||||
|
||||
var client model.OidcClient
|
||||
if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if client.ImageType != nil && *client.ImageType != ext {
|
||||
old := fmt.Sprintf("%s/%s.%s", uploadsDir, client.ID, *client.ImageType)
|
||||
_ = os.Remove(old)
|
||||
}
|
||||
client.ImageType = &ext
|
||||
return tx.WithContext(ctx).Save(&client).Error
|
||||
|
||||
}
|
||||
|
||||
@@ -244,6 +244,10 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
|
||||
}
|
||||
|
||||
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
user := model.User{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
@@ -339,6 +343,10 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser
|
||||
}
|
||||
|
||||
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
|
||||
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil {
|
||||
return model.User{}, &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
@@ -437,6 +445,10 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
return err
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -464,7 +476,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
|
||||
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: user.Email,
|
||||
Email: *user.Email,
|
||||
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
@@ -472,7 +484,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
ExpirationString: utils.DurationToString(ttl),
|
||||
})
|
||||
if errInternal != nil {
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email))
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -57,6 +58,34 @@ func GetImageMimeType(ext string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func GetImageExtensionFromMimeType(mimeType string) string {
|
||||
// Normalize and strip parameters like `; charset=utf-8`
|
||||
mt := strings.TrimSpace(strings.ToLower(mimeType))
|
||||
if v, _, err := mime.ParseMediaType(mt); err == nil {
|
||||
mt = v
|
||||
}
|
||||
switch mt {
|
||||
case "image/jpeg", "image/jpg":
|
||||
return "jpg"
|
||||
case "image/png":
|
||||
return "png"
|
||||
case "image/svg+xml":
|
||||
return "svg"
|
||||
case "image/x-icon", "image/vnd.microsoft.icon":
|
||||
return "ico"
|
||||
case "image/gif":
|
||||
return "gif"
|
||||
case "image/webp":
|
||||
return "webp"
|
||||
case "image/avif":
|
||||
return "avif"
|
||||
case "image/heic", "image/heif":
|
||||
return "heic"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := resources.FS.Open(srcFilePath)
|
||||
if err != nil {
|
||||
|
||||
87
backend/internal/utils/ip_util.go
Normal file
87
backend/internal/utils/ip_util.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
var localIPv6Ranges []*net.IPNet
|
||||
|
||||
var localhostIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
|
||||
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
|
||||
}
|
||||
|
||||
var privateLanIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
|
||||
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
|
||||
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
|
||||
}
|
||||
|
||||
var tailscaleIPNets = []*net.IPNet{
|
||||
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
|
||||
}
|
||||
|
||||
func IsLocalIPv6(ip net.IP) bool {
|
||||
if ip.To4() != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return listContainsIP(localIPv6Ranges, ip)
|
||||
}
|
||||
|
||||
func IsLocalhostIP(ip net.IP) bool {
|
||||
return listContainsIP(localhostIPNets, ip)
|
||||
}
|
||||
|
||||
func IsPrivateLanIP(ip net.IP) bool {
|
||||
if ip.To4() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return listContainsIP(privateLanIPNets, ip)
|
||||
}
|
||||
|
||||
func IsTailscaleIP(ip net.IP) bool {
|
||||
if ip.To4() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return listContainsIP(tailscaleIPNets, ip)
|
||||
}
|
||||
|
||||
func IsPrivateIP(ip net.IP) bool {
|
||||
return IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip)
|
||||
}
|
||||
|
||||
func listContainsIP(ipNets []*net.IPNet, ip net.IP) bool {
|
||||
for _, ipNet := range ipNets {
|
||||
if ipNet.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func loadLocalIPv6Ranges() {
|
||||
localIPv6Ranges = nil
|
||||
ranges := strings.Split(common.EnvConfig.LocalIPv6Ranges, ",")
|
||||
|
||||
for _, rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err == nil {
|
||||
localIPv6Ranges = append(localIPv6Ranges, ipNet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
loadLocalIPv6Ranges()
|
||||
}
|
||||
159
backend/internal/utils/ip_util_test.go
Normal file
159
backend/internal/utils/ip_util_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func TestIsLocalhostIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true},
|
||||
{"127.255.255.255", true},
|
||||
{"::1", true},
|
||||
{"192.168.1.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := IsLocalhostIP(ip); got != tt.expected {
|
||||
t.Errorf("IsLocalhostIP(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateLanIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"10.0.0.1", true},
|
||||
{"172.16.5.4", true},
|
||||
{"192.168.100.200", true},
|
||||
{"8.8.8.8", false},
|
||||
{"::1", false}, // IPv6 should return false
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := IsPrivateLanIP(ip); got != tt.expected {
|
||||
t.Errorf("IsPrivateLanIP(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTailscaleIP(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"100.64.0.1", true},
|
||||
{"100.127.255.254", true},
|
||||
{"8.8.8.8", false},
|
||||
{"::1", false}, // IPv6 should return false
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := IsTailscaleIP(ip); got != tt.expected {
|
||||
t.Errorf("IsTailscaleIP(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLocalIPv6(t *testing.T) {
|
||||
// Save and restore env config
|
||||
origRanges := common.EnvConfig.LocalIPv6Ranges
|
||||
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
|
||||
|
||||
common.EnvConfig.LocalIPv6Ranges = "fd00::/8,fc00::/7"
|
||||
localIPv6Ranges = nil // reset
|
||||
loadLocalIPv6Ranges()
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"fd00::1", true},
|
||||
{"fc00::abcd", true},
|
||||
{"::1", false}, // loopback handled separately
|
||||
{"192.168.1.1", false}, // IPv4 should return false
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := IsLocalIPv6(ip); got != tt.expected {
|
||||
t.Errorf("IsLocalIPv6(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateIP(t *testing.T) {
|
||||
// Save and restore env config
|
||||
origRanges := common.EnvConfig.LocalIPv6Ranges
|
||||
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
|
||||
|
||||
common.EnvConfig.LocalIPv6Ranges = "fd00::/8"
|
||||
localIPv6Ranges = nil // reset
|
||||
loadLocalIPv6Ranges()
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"127.0.0.1", true}, // localhost
|
||||
{"192.168.1.1", true}, // private LAN
|
||||
{"100.64.0.1", true}, // Tailscale
|
||||
{"fd00::1", true}, // local IPv6
|
||||
{"8.8.8.8", false}, // public IPv4
|
||||
{"2001:4860:4860::8888", false}, // public IPv6
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := IsPrivateIP(ip); got != tt.expected {
|
||||
t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListContainsIP(t *testing.T) {
|
||||
_, ipNet1, _ := net.ParseCIDR("10.0.0.0/8")
|
||||
_, ipNet2, _ := net.ParseCIDR("192.168.0.0/16")
|
||||
|
||||
list := []*net.IPNet{ipNet1, ipNet2}
|
||||
|
||||
tests := []struct {
|
||||
ip string
|
||||
expected bool
|
||||
}{
|
||||
{"10.1.1.1", true},
|
||||
{"192.168.5.5", true},
|
||||
{"172.16.0.1", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
ip := net.ParseIP(tt.ip)
|
||||
if got := listContainsIP(list, ip); got != tt.expected {
|
||||
t.Errorf("listContainsIP(%s) = %v, want %v", tt.ip, got, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_LocalIPv6Ranges(t *testing.T) {
|
||||
// Save and restore env config
|
||||
origRanges := common.EnvConfig.LocalIPv6Ranges
|
||||
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
|
||||
|
||||
common.EnvConfig.LocalIPv6Ranges = "fd00::/8, invalidCIDR ,fc00::/7"
|
||||
localIPv6Ranges = nil
|
||||
loadLocalIPv6Ranges()
|
||||
|
||||
if len(localIPv6Ranges) != 2 {
|
||||
t.Errorf("expected 2 valid IPv6 ranges, got %d", len(localIPv6Ranges))
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
package utils
|
||||
|
||||
// Ptr returns a pointer to the given value.
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func PtrValueOrZero[T any](ptr *T) T {
|
||||
if ptr == nil {
|
||||
var zero T
|
||||
return zero
|
||||
// PtrOrNil returns a pointer to v if v is not the zero value of its type,
|
||||
// otherwise it returns nil.
|
||||
func PtrOrNil[T comparable](v T) *T {
|
||||
var zero T
|
||||
if v == zero {
|
||||
return nil
|
||||
}
|
||||
return *ptr
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
|
||||
|
||||
// Connect to a new in-memory SQL database
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
|
||||
sqlite.Open("file:"+dbName+"?mode=memory"),
|
||||
&gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: logger.New(
|
||||
@@ -52,9 +52,14 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
|
||||
})
|
||||
require.NoError(t, err, "Failed to connect to test database")
|
||||
|
||||
// Perform migrations with the embedded migrations
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err, "Failed to get sql.DB")
|
||||
|
||||
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
|
||||
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
|
||||
// Perform migrations with the embedded migrations
|
||||
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{
|
||||
NoTxWrap: true,
|
||||
})
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,3 @@
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column">
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column">
|
||||
<p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
@@ -1,5 +1,5 @@
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
|
||||
<p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p>
|
||||
<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.City}}<!-- -->, <!-- -->{{.Data.Country}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">
|
||||
{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p>
|
||||
<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
@@ -12,7 +12,8 @@ DETAILS
|
||||
|
||||
Approximate Location
|
||||
|
||||
{{.Data.City}}, {{.Data.Country}}
|
||||
{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
|
||||
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
|
||||
|
||||
IP Address
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
|
||||
Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>   </i><![endif]--></span>
|
||||
<span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->
|
||||
{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>   </i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">
|
||||
Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>   ​</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
@@ -1,3 +1,3 @@
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
|
||||
Your email setup is working correctly!</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px">
|
||||
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td>
|
||||
</tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op because email was optional before the migration
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op because email was optional before the migration
|
||||
@@ -0,0 +1,40 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE users_new
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||
email TEXT UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
is_admin NUMERIC NOT NULL DEFAULT FALSE,
|
||||
ldap_id TEXT,
|
||||
locale TEXT,
|
||||
disabled NUMERIC NOT NULL DEFAULT FALSE
|
||||
);
|
||||
|
||||
INSERT INTO users_new (id, created_at, username, email, first_name, last_name, display_name, is_admin, ldap_id, locale,
|
||||
disabled)
|
||||
SELECT id,
|
||||
created_at,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
is_admin,
|
||||
ldap_id,
|
||||
locale,
|
||||
disabled
|
||||
FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
|
||||
ALTER TABLE users_new
|
||||
RENAME TO users;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys = ON;
|
||||
12
cliff.toml
12
cliff.toml
@@ -27,16 +27,12 @@ body = """
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | title }}
|
||||
{% for commit in commits %}
|
||||
* {{ commit.message }} \
|
||||
{%- if commit.remote.pr_number -%}
|
||||
([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
|
||||
{%- else -%}
|
||||
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
|
||||
{%- endif -%}
|
||||
- {{ commit.message | trim }} \
|
||||
{%- if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) by @{{ commit.remote.username | default(value=commit.author.name) }}){%- else %} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}) by @{{ commit.remote.username | default(value=commit.author.name) }}){%- endif -%}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% if version %}
|
||||
{% if previous.version %}
|
||||
{% if version -%}
|
||||
{% if previous.version -%}
|
||||
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
|
||||
{% endif %}
|
||||
{% else -%}
|
||||
|
||||
@@ -21,10 +21,6 @@ export const BaseTemplate = ({
|
||||
appName,
|
||||
children,
|
||||
}: BaseTemplateProps) => {
|
||||
const finalLogoURL =
|
||||
logoURL ||
|
||||
"https://private-user-images.githubusercontent.com/58886915/359183039-4ceb2708-9f29-4694-b797-be833efce17d.png?jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3NTY0NTk5MzksIm5iZiI6MTc1NjQ1OTYzOSwicGF0aCI6Ii81ODg4NjkxNS8zNTkxODMwMzktNGNlYjI3MDgtOWYyOS00Njk0LWI3OTctYmU4MzNlZmNlMTdkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNTA4MjklMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjUwODI5VDA5MjcxOVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWM4ZWI5NzlkMDA5NDNmZGU5MjQwMGE1YjA0NWZiNzEzM2E0MzAzOTFmOWRmNDUzNmJmNjQwZTMxNGIzZmMyYmQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0In0.YdfLv1tD5KYnRZPSA3QlR1SsvScpP0rt-J3YD6ZHsCk";
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
@@ -32,25 +28,24 @@ export const BaseTemplate = ({
|
||||
<Container style={{ width: "500px", margin: "0 auto" }}>
|
||||
<Section>
|
||||
<Row
|
||||
align="left"
|
||||
style={{
|
||||
width: "210px",
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<Column>
|
||||
<Img
|
||||
src={finalLogoURL}
|
||||
width="32"
|
||||
height="32"
|
||||
alt={appName}
|
||||
style={logoStyle}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={titleStyle}>{appName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
align="left"
|
||||
style={{
|
||||
marginBottom: "16px",
|
||||
}}
|
||||
>
|
||||
<Column style={{ width: "50px" }}>
|
||||
<Img
|
||||
src={logoURL}
|
||||
width="32"
|
||||
height="32"
|
||||
alt={appName}
|
||||
style={logoStyle}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={titleStyle}>{appName}</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
</Section>
|
||||
<div style={content}>{children}</div>
|
||||
</Container>
|
||||
@@ -69,7 +64,6 @@ const logoStyle = {
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
verticalAlign: "middle",
|
||||
marginRight: "8px",
|
||||
};
|
||||
|
||||
const titleStyle = {
|
||||
|
||||
@@ -4,8 +4,7 @@ import CardHeader from "../components/card-header";
|
||||
import { sharedPreviewProps, sharedTemplateProps } from "../props";
|
||||
|
||||
interface SignInData {
|
||||
city?: string;
|
||||
country?: string;
|
||||
location: string;
|
||||
ipAddress: string;
|
||||
device: string;
|
||||
dateTime: string;
|
||||
@@ -42,9 +41,7 @@ export const NewSignInEmail = ({
|
||||
<Row>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>Approximate Location</Text>
|
||||
<Text style={detailsBoxValueStyle}>
|
||||
{data.city}, {data.country}
|
||||
</Text>
|
||||
<Text style={detailsBoxValueStyle}>{data.location}</Text>
|
||||
</Column>
|
||||
<Column style={detailsBoxStyle}>
|
||||
<Text style={detailsLabelStyle}>IP Address</Text>
|
||||
@@ -84,8 +81,7 @@ const detailsBoxValueStyle = {
|
||||
NewSignInEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
city: "{{.Data.City}}",
|
||||
country: "{{.Data.Country}}",
|
||||
location: "{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}",
|
||||
ipAddress: "{{.Data.IPAddress}}",
|
||||
device: "{{.Data.Device}}",
|
||||
dateTime: '{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}',
|
||||
@@ -95,8 +91,7 @@ NewSignInEmail.TemplateProps = {
|
||||
NewSignInEmail.PreviewProps = {
|
||||
...sharedPreviewProps,
|
||||
data: {
|
||||
city: "San Francisco",
|
||||
country: "USA",
|
||||
location: "San Francisco, USA",
|
||||
ipAddress: "127.0.0.1",
|
||||
device: "Chrome on macOS",
|
||||
dateTime: "2024-01-01 12:00 PM UTC",
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Namísto toho použít svůj přístupový klíč?",
|
||||
"email_login": "Přihlášení e-mailem",
|
||||
"enter_a_login_code_to_sign_in": "Pro přihlášení zadejte přihlašovací kód.",
|
||||
"sign_in_with_login_code": "Přihlaste se pomocí přihlašovacího kódu",
|
||||
"request_a_login_code_via_email": "Požádat o přihlášení pomocí e-mailu.",
|
||||
"go_back": "Jít zpět",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Na zadaný e-mail byl zaslán e-mail, pokud existuje v systému.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Jméno přístupového klíče",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Pojmenujte Váš přístupový klíč, abyste ho snadno identifikovali později.",
|
||||
"create_api_key": "Vytvořit API klíč",
|
||||
"add_a_new_api_key_for_programmatic_access": "Přidejte nový API klíč pro programový přístup.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Přidejte nový klíč API pro programový přístup k <link href='https://pocket-id.org/docs/api'>API Pocket ID</link>.",
|
||||
"add_api_key": "Přidat API klíč",
|
||||
"manage_api_keys": "Spravovat API klíče",
|
||||
"api_key_created": "API klíč vytvořen",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Nejsou k dispozici žádná náhledová data",
|
||||
"copy_all": "Kopírovat vše",
|
||||
"preview": "Náhled",
|
||||
"preview_for_user": "Náhled pro {name} ({email})",
|
||||
"preview_for_user": "Náhled pro {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Náhled OIDC dat, která by byla odeslána pro uživatele",
|
||||
"show": "Zobrazit",
|
||||
"select_an_option": "Vyberte možnost",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Zobrazované jméno",
|
||||
"configure_application_images": "Konfigurace obrazů aplikací",
|
||||
"ui_config_disabled_info_title": "Konfigurace uživatelského rozhraní je deaktivována",
|
||||
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná."
|
||||
"ui_config_disabled_info_description": "Konfigurace uživatelského rozhraní je deaktivována, protože nastavení konfigurace aplikace se spravuje prostřednictvím proměnných prostředí. Některá nastavení nemusí být editovatelná.",
|
||||
"logo_from_url_description": "Vložte přímou URL adresu obrázku (svg, png, webp). Ikony najdete na <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> nebo <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Neplatná adresa URL",
|
||||
"require_user_email": "Vyžadovat e-mailovou adresu",
|
||||
"require_user_email_description": "Vyžaduje, aby uživatelé měli e-mailovou adresu. Pokud je tato možnost deaktivována, uživatelé bez e-mailové adresy nebudou moci používat funkce, které e-mailovou adresu vyžadují."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Vil du i stedet bruge din adgangsnøgle?",
|
||||
"email_login": "E-mail Login",
|
||||
"enter_a_login_code_to_sign_in": "Indtast en loginkode for at logge ind.",
|
||||
"sign_in_with_login_code": "Log ind med login-kode",
|
||||
"request_a_login_code_via_email": "Anmod om en loginkode via e-mail.",
|
||||
"go_back": "Gå tilbage",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "En e-mail er blevet sendt til den angivne e-mailadresse, hvis den findes i systemet.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Navngiv adgangsnøgle",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Giv din adgangsnøgle et navn, så du nemt kan genkende den senere.",
|
||||
"create_api_key": "Opret API-nøgle",
|
||||
"add_a_new_api_key_for_programmatic_access": "Tilføj en ny API-nøgle til programmatisk adgang.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Tilføj en ny API-nøgle for programmatisk adgang til <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Tilføj API-nøgle",
|
||||
"manage_api_keys": "Administrér API-nøgler",
|
||||
"api_key_created": "API-nøgle oprettet",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Ingen forhåndsvisningsdata tilgængelig",
|
||||
"copy_all": "Kopiér alt",
|
||||
"preview": "Forhåndsvisning",
|
||||
"preview_for_user": "Forhåndsvisning for {name} ({email})",
|
||||
"preview_for_user": "Forhåndsvisning for {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Forhåndsvis OIDC-data, der ville blive sendt for denne bruger",
|
||||
"show": "Vis",
|
||||
"select_an_option": "Vælg en indstilling",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Visningsnavn",
|
||||
"configure_application_images": "Konfigurer applikationsbilleder",
|
||||
"ui_config_disabled_info_title": "UI-konfiguration deaktiveret",
|
||||
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres."
|
||||
"ui_config_disabled_info_description": "UI-konfigurationen er deaktiveret, fordi applikationskonfigurationsindstillingerne administreres via miljøvariabler. Nogle indstillinger kan muligvis ikke redigeres.",
|
||||
"logo_from_url_description": "Indsæt en direkte billed-URL (svg, png, webp). Find ikoner på <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> eller <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Ugyldig URL",
|
||||
"require_user_email": "Kræver e-mailadresse",
|
||||
"require_user_email_description": "Kræver, at brugerne har en e-mailadresse. Hvis denne funktion er deaktiveret, kan brugere uden en e-mailadresse ikke bruge funktioner, der kræver en e-mailadresse."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?",
|
||||
"email_login": "E-Mail Anmeldung",
|
||||
"enter_a_login_code_to_sign_in": "Gib einen Anmeldecode zum Anmelden ein.",
|
||||
"sign_in_with_login_code": "Mit Login-Code anmelden",
|
||||
"request_a_login_code_via_email": "Login-Code per E-Mail anfordern.",
|
||||
"go_back": "Zurück",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Eine E-Mail wurde an die angegebene E-Mail gesendet, sofern sie im System vorhanden ist.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Passkey benennen",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Benenne deinen Passkey, um ihn später leicht identifizieren zu können.",
|
||||
"create_api_key": "API-Schlüssel erstellen",
|
||||
"add_a_new_api_key_for_programmatic_access": "Füge einen neuen API-Schlüssel für programmatischen Zugriff hinzu.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Füge einen neuen API-Schlüssel für den programmgesteuerten Zugriff auf die <link href='https://pocket-id.org/docs/api'>Pocket ID API</link> hinzu.",
|
||||
"add_api_key": "API-Schlüssel hinzufügen",
|
||||
"manage_api_keys": "API-Schlüssel verwalten",
|
||||
"api_key_created": "API-Schlüssel erstellt",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Keine Vorschaudaten verfügbar",
|
||||
"copy_all": "Alles kopieren",
|
||||
"preview": "Vorschau",
|
||||
"preview_for_user": "Vorschau für {name} ({email})",
|
||||
"preview_for_user": "Vorschau für {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Vorschau der OIDC-Daten, für diesen Benutzer",
|
||||
"show": "Anzeigen",
|
||||
"select_an_option": "Wähle eine Option",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Anzeigename",
|
||||
"configure_application_images": "Anwendungsimages einrichten",
|
||||
"ui_config_disabled_info_title": "UI-Konfiguration deaktiviert",
|
||||
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden."
|
||||
"ui_config_disabled_info_description": "Die UI-Konfiguration ist deaktiviert, weil die Anwendungseinstellungen über Umgebungsvariablen verwaltet werden. Manche Einstellungen können vielleicht nicht geändert werden.",
|
||||
"logo_from_url_description": "Füge eine direkte Bild-URL ein (svg, png, webp). Finde Symbole bei <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> oder <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Ungültige URL",
|
||||
"require_user_email": "E-Mail-Adresse erforderlich",
|
||||
"require_user_email_description": "Benutzer müssen eine E-Mail-Adresse haben. Wenn das deaktiviert ist, können Leute ohne E-Mail-Adresse die Funktionen, die eine E-Mail-Adresse brauchen, nicht nutzen."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Use your passkey instead?",
|
||||
"email_login": "Email Login",
|
||||
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
|
||||
"sign_in_with_login_code": "Sign in with login code",
|
||||
"request_a_login_code_via_email": "Request a login code via email.",
|
||||
"go_back": "Go back",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Name Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
|
||||
"create_api_key": "Create API Key",
|
||||
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Add API Key",
|
||||
"manage_api_keys": "Manage API Keys",
|
||||
"api_key_created": "API Key Created",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "No preview data available",
|
||||
"copy_all": "Copy All",
|
||||
"preview": "Preview",
|
||||
"preview_for_user": "Preview for {name} ({email})",
|
||||
"preview_for_user": "Preview for {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||
"show": "Show",
|
||||
"select_an_option": "Select an option",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Display Name",
|
||||
"configure_application_images": "Configure Application Images",
|
||||
"ui_config_disabled_info_title": "UI Configuration Disabled",
|
||||
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable."
|
||||
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
|
||||
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> or <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Invalid URL",
|
||||
"require_user_email": "Require Email Address",
|
||||
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "¿Utilizar su Passkey en su lugar?",
|
||||
"email_login": "Ingreso con Email",
|
||||
"enter_a_login_code_to_sign_in": "Introduzca un código de acceso para iniciar sesión.",
|
||||
"sign_in_with_login_code": "Inicia sesión con tu código de acceso.",
|
||||
"request_a_login_code_via_email": "Solicitar un código de acceso por correo electrónico.",
|
||||
"go_back": "Volver atrás",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Se ha enviado un correo electrónico al correo proporcionado, si existe en el sistema.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Nombre para la clave de acceso",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Nombra tu clave de acceso para poder identificarla fácilmente más tarde.",
|
||||
"create_api_key": "Crear API Key",
|
||||
"add_a_new_api_key_for_programmatic_access": "Añade una nueva API key para el acceso programático.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Añade una nueva clave API para acceder de forma programática a la <link href='https://pocket-id.org/docs/api'>API de Pocket ID</link>.",
|
||||
"add_api_key": "Añade una API Key",
|
||||
"manage_api_keys": "Gestiona las API Keys",
|
||||
"api_key_created": "API Key creada",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "No hay datos de vista previa disponibles.",
|
||||
"copy_all": "Copiar todo",
|
||||
"preview": "Vista previa",
|
||||
"preview_for_user": "Vista previa de « {name} » ({email})",
|
||||
"preview_for_user": "Vista previa de « {name} »",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Previsualiza los datos OIDC que se enviarían para este usuario.",
|
||||
"show": "Mostrar",
|
||||
"select_an_option": "Selecciona una opción",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Nombre para mostrar",
|
||||
"configure_application_images": "Configurar imágenes de aplicaciones",
|
||||
"ui_config_disabled_info_title": "Configuración de la interfaz de usuario desactivada",
|
||||
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar."
|
||||
"ui_config_disabled_info_description": "La configuración de la interfaz de usuario está desactivada porque los ajustes de configuración de la aplicación se gestionan a través de variables de entorno. Es posible que algunos ajustes no se puedan editar.",
|
||||
"logo_from_url_description": "Pega una URL de imagen directa (svg, png, webp). Encuentra iconos en <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> o <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "URL no válida",
|
||||
"require_user_email": "Requerir dirección de correo electrónico",
|
||||
"require_user_email_description": "Requiere que los usuarios tengan una dirección de correo electrónico. Si se desactiva, los usuarios que no tengan una dirección de correo electrónico no podrán utilizar las funciones que la requieran."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Utiliser votre clé d'accès à la place ?",
|
||||
"email_login": "Connexion par e-mail",
|
||||
"enter_a_login_code_to_sign_in": "Entrez un code de connexion pour vous connecter.",
|
||||
"sign_in_with_login_code": "Connecte-toi avec ton code d'accès",
|
||||
"request_a_login_code_via_email": "Demander un code de connexion par e-mail.",
|
||||
"go_back": "Retour",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Un e-mail a été envoyé à l'e-mail mentionné, si elle existe dans le système.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Nom de la clé d'accès",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Nommez votre clé d'accès pour l'identifier plus tard.",
|
||||
"create_api_key": "Créer une clé API",
|
||||
"add_a_new_api_key_for_programmatic_access": "Ajouter une nouvelle clé API pour l'accès par des programmes tiers.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Ajoute une nouvelle clé API pour accéder de manière programmatique à <link href='https://pocket-id.org/docs/api'>l'API Pocket ID</link>.",
|
||||
"add_api_key": "Crée une clé API",
|
||||
"manage_api_keys": "Gérer les clés API",
|
||||
"api_key_created": "Clé API créée",
|
||||
@@ -318,9 +319,9 @@
|
||||
"reset_to_default": "Valeurs par défaut",
|
||||
"profile_picture_has_been_reset": "La photo de profil a été réinitialisée. La mise à jour peut prendre quelques minutes.",
|
||||
"select_the_language_you_want_to_use": "Choisis la langue que tu veux utiliser. Attention, certains textes peuvent être traduits automatiquement et ne pas être tout à fait exacts.",
|
||||
"contribute_to_translation": "Si tu trouves un problème, n'hésite pas à contribuer à la traduction sur <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||
"contribute_to_translation": "Si vous trouvez un problème, n'hésitez pas à contribuer à la traduction sur <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||
"personal": "Personnel",
|
||||
"global": "Mondial",
|
||||
"global": "Global",
|
||||
"all_users": "Tous les utilisateurs",
|
||||
"all_events": "Tous les événements",
|
||||
"all_clients": "Tous les clients",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Aucune donnée d'aperçu disponible",
|
||||
"copy_all": "Tout copier",
|
||||
"preview": "Aperçu",
|
||||
"preview_for_user": "Aperçu pour {name} ({email})",
|
||||
"preview_for_user": "Aperçu pour {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Aperçu des données OIDC qui seraient envoyées pour cet utilisateur",
|
||||
"show": "Afficher",
|
||||
"select_an_option": "Sélectionner une option",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Nom d'affichage",
|
||||
"configure_application_images": "Configurer les images d'application",
|
||||
"ui_config_disabled_info_title": "Configuration de l'interface utilisateur désactivée",
|
||||
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables."
|
||||
"ui_config_disabled_info_description": "La configuration de l'interface utilisateur est désactivée parce que les paramètres de configuration de l'application sont gérés par des variables d'environnement. Certains paramètres peuvent ne pas être modifiables.",
|
||||
"logo_from_url_description": "Colle une URL d'image directe (svg, png, webp). Trouve des icônes sur <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> ou <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "URL pas valide",
|
||||
"require_user_email": "Besoin d'une adresse e-mail",
|
||||
"require_user_email_description": "Les utilisateurs doivent avoir une adresse e-mail. Si cette option est désactivée, ceux qui n'ont pas d'adresse e-mail ne pourront pas utiliser les fonctionnalités qui en ont besoin."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Usare invece la tua passkey?",
|
||||
"email_login": "Accesso Email",
|
||||
"enter_a_login_code_to_sign_in": "Inserisci un codice di accesso per accedere.",
|
||||
"sign_in_with_login_code": "Accedi con il codice di accesso",
|
||||
"request_a_login_code_via_email": "Richiedi un codice di accesso via email.",
|
||||
"go_back": "Torna indietro",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "È stata inviata un'email all'indirizzo fornito, se esiste nel sistema.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Nome Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Dai un nome alla tua passkey per identificarla facilmente in seguito.",
|
||||
"create_api_key": "Crea Chiave API",
|
||||
"add_a_new_api_key_for_programmatic_access": "Aggiungi una nuova chiave API per l'accesso programmatico.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Aggiungi una nuova chiave API per accedere in modo programmatico <link href='https://pocket-id.org/docs/api'>all'API Pocket ID</link>.",
|
||||
"add_api_key": "Aggiungi Chiave API",
|
||||
"manage_api_keys": "Gestisci Chiavi API",
|
||||
"api_key_created": "Chiave API Creata",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Dati di anteprima non disponibili",
|
||||
"copy_all": "Copia tutto",
|
||||
"preview": "Anteprima",
|
||||
"preview_for_user": "Anteprima per {name} ({email})",
|
||||
"preview_for_user": "Anteprima per {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Anteprima dei dati OIDC che saranno inviati per l'utente",
|
||||
"show": "Mostra",
|
||||
"select_an_option": "Seleziona un'opzione",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Nome visualizzato",
|
||||
"configure_application_images": "Configurare le immagini dell'applicazione",
|
||||
"ui_config_disabled_info_title": "Configurazione dell'interfaccia utente disattivata",
|
||||
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili."
|
||||
"ui_config_disabled_info_description": "La configurazione dell'interfaccia utente è disattivata perché le impostazioni di configurazione dell'applicazione sono gestite tramite variabili di ambiente. Alcune impostazioni potrebbero non essere modificabili.",
|
||||
"logo_from_url_description": "Incolla l'URL diretto dell'immagine (svg, png, webp). Trova le icone su <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> o <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "URL non valido",
|
||||
"require_user_email": "Richiesta indirizzo e-mail",
|
||||
"require_user_email_description": "Chiede agli utenti di avere un indirizzo email. Se disattivato, chi non ha un indirizzo email non potrà usare le funzioni che lo richiedono."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "대신 패스키 이용하기",
|
||||
"email_login": "이메일 로그인",
|
||||
"enter_a_login_code_to_sign_in": "로그인 코드를 입력하여 로그인하세요.",
|
||||
"sign_in_with_login_code": "로그인 코드로 로그인",
|
||||
"request_a_login_code_via_email": "이메일로 로그인 코드를 요청합니다.",
|
||||
"go_back": "뒤로 가기",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "입력한 이메일 주소가 시스템에 존재하는 경우 이메일이 발송됩니다.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "패스키 이름",
|
||||
"name_your_passkey_to_easily_identify_it_later": "패스키의 이름을 지정하여 나중에 쉽게 구분할 수 있도록 합니다.",
|
||||
"create_api_key": "API 키 생성",
|
||||
"add_a_new_api_key_for_programmatic_access": "프로그램 접근을 위해 새로운 API 키를 추가합니다.",
|
||||
"add_a_new_api_key_for_programmatic_access": "<link href='https://pocket-id.org/docs/api'>Pocket ID API에</link> 대한 프로그래매틱 액세스를 위해 새 API 키를 추가하세요.",
|
||||
"add_api_key": "API 키 추가",
|
||||
"manage_api_keys": "API 키 관리",
|
||||
"api_key_created": "API 키 생성됨",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "미리보기 데이터가 없습니다",
|
||||
"copy_all": "모두 복사",
|
||||
"preview": "미리보기",
|
||||
"preview_for_user": "{name} ({email}) 미리보기",
|
||||
"preview_for_user": "{name} 미리보기",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "이 사용자를 위해 전송될 OIDC 데이터 미리보기",
|
||||
"show": "표시",
|
||||
"select_an_option": "옵션 선택",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "표시 이름",
|
||||
"configure_application_images": "애플리케이션 이미지 구성",
|
||||
"ui_config_disabled_info_title": "UI 구성 비활성화됨",
|
||||
"ui_config_disabled_info_description": "UI 구성이 비활성화되었습니다. 애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문입니다. 일부 설정은 편집할 수 없을 수 있습니다."
|
||||
"ui_config_disabled_info_description": "애플리케이션 구성 설정은 환경 변수를 통해 관리되기 때문에 UI 구성이 비활성화되었습니다. 일부 설정을 편집할 수 없을 수 있습니다.",
|
||||
"logo_from_url_description": "이미지 다이렉트 URL (svg, png, webp)을 붙여넣으세요. <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> 또는 <link href=\"https://dashboardicons.com\">Dashboard Icons</link>에서 아이콘을 찾으세요.",
|
||||
"invalid_url": "잘못된 URL",
|
||||
"require_user_email": "이메일 주소 필수",
|
||||
"require_user_email_description": "사용자에게 이메일 주소가 있어야 합니다. 이 설정이 비활성화되면 이메일 주소가 없는 사용자는 이메일 주소가 필요한 기능을 사용할 수 없습니다."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Wil je in plaats daarvan je passkey gebruiken?",
|
||||
"email_login": "Inloggen met e-mail",
|
||||
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
|
||||
"sign_in_with_login_code": "Log in met je inlogcode",
|
||||
"request_a_login_code_via_email": "Vraag een inlogcode aan via e-mail.",
|
||||
"go_back": "Terug",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien dit in het systeem voorkomt.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Naam passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Geef je passkey een naam, zodat je deze later gemakkelijk kunt terugvinden.",
|
||||
"create_api_key": "API-sleutel aanmaken",
|
||||
"add_a_new_api_key_for_programmatic_access": "Voeg een nieuwe API-sleutel toe voor programmatische toegang.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Voeg een nieuwe API-sleutel toe voor toegang tot de <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "API-sleutel toevoegen",
|
||||
"manage_api_keys": "API-sleutels beheren",
|
||||
"api_key_created": "API-sleutel gemaakt",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Geen voorbeeldgegevens beschikbaar",
|
||||
"copy_all": "Alles kopiëren",
|
||||
"preview": "Voorbeeld",
|
||||
"preview_for_user": "Voorbeeld van {name} ({email})",
|
||||
"preview_for_user": "Voorbeeld van {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Bekijk een voorbeeld van de OIDC-gegevens die voor deze gebruiker zouden worden verzonden.",
|
||||
"show": "Laten zien",
|
||||
"select_an_option": "Kies een optie",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Weergavenaam",
|
||||
"configure_application_images": "Configureer applicatieafbeeldingen",
|
||||
"ui_config_disabled_info_title": "UI-configuratie uitgeschakeld",
|
||||
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen."
|
||||
"ui_config_disabled_info_description": "De UI-configuratie is uitgeschakeld omdat de configuratie-instellingen van de app via omgevingsvariabelen worden beheerd. Sommige instellingen kun je misschien niet aanpassen.",
|
||||
"logo_from_url_description": "Plak een directe afbeeldings-URL (svg, png, webp). Zoek pictogrammen op <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> of <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Ongeldige URL",
|
||||
"require_user_email": "E-mailadres vereist",
|
||||
"require_user_email_description": "Je moet een e-mailadres hebben. Als je dit uitschakelt, kunnen mensen zonder e-mailadres geen functies gebruiken waarvoor een e-mailadres nodig is."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Użyj swojego klucza zamiast tego?",
|
||||
"email_login": "Logowanie przez e-mail",
|
||||
"enter_a_login_code_to_sign_in": "Wprowadź kod logowania, aby się zalogować.",
|
||||
"sign_in_with_login_code": "Zaloguj się za pomocą kodu logowania",
|
||||
"request_a_login_code_via_email": "Poproś o kod logowania przez e-mail.",
|
||||
"go_back": "Wróć",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "E-mail został wysłany na podany adres, jeśli istnieje w systemie.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Nazwa klucza",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Nazwij swój klucz, aby łatwo go zidentyfikować później.",
|
||||
"create_api_key": "Utwórz klucz API",
|
||||
"add_a_new_api_key_for_programmatic_access": "Dodaj nowy klucz API dla dostępu programowego.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Dodaj nowy klucz API, aby uzyskać programowy dostęp do interfejsu <link href='https://pocket-id.org/docs/api'>API Pocket ID</link>.",
|
||||
"add_api_key": "Dodaj klucz API",
|
||||
"manage_api_keys": "Zarządzaj kluczami API",
|
||||
"api_key_created": "Sukces! Klucz API został utworzony.",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Brak dostępnych danych podglądu",
|
||||
"copy_all": "Skopiuj wszystko",
|
||||
"preview": "Podgląd",
|
||||
"preview_for_user": "Zapowiedź książki „ {name} ” ({email})",
|
||||
"preview_for_user": "Zapowiedź książki „ {name} ”",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Wyświetl podgląd danych OIDC, które zostaną wysłane dla tego użytkownika.",
|
||||
"show": "Pokaż",
|
||||
"select_an_option": "Wybierz opcję",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Wyświetlana nazwa",
|
||||
"configure_application_images": "Konfigurowanie obrazów aplikacji",
|
||||
"ui_config_disabled_info_title": "Konfiguracja interfejsu użytkownika wyłączona",
|
||||
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne."
|
||||
"ui_config_disabled_info_description": "Konfiguracja interfejsu użytkownika jest wyłączona, ponieważ ustawienia konfiguracyjne aplikacji są zarządzane za pomocą zmiennych środowiskowych. Niektóre ustawienia mogą nie być edytowalne.",
|
||||
"logo_from_url_description": "Wklej bezpośredni adres URL obrazu (svg, png, webp). Znajdź ikony na stronie <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> lub <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Nieprawidłowy adres URL",
|
||||
"require_user_email": "Wymagany adres e-mail",
|
||||
"require_user_email_description": "Wymaga od was posiadania adresu e-mail. Jeśli opcja zostanie wyłączona, wy bez adresu e-mail nie będziecie mogli korzystać z funkcji wymagających adresu e-mail."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Quer usar sua chave de acesso?",
|
||||
"email_login": "Entrar com e-mail",
|
||||
"enter_a_login_code_to_sign_in": "Digite um código de login para entrar.",
|
||||
"sign_in_with_login_code": "Faça login com o código de acesso",
|
||||
"request_a_login_code_via_email": "Pede um código de login por e-mail.",
|
||||
"go_back": "Voltar",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Mandamos um e-mail pro endereço que você deu, se ele estiver no nosso sistema.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Nome da chave de acesso",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Dê um nome à sua chave de acesso para identificá-la facilmente mais tarde.",
|
||||
"create_api_key": "Criar chave API",
|
||||
"add_a_new_api_key_for_programmatic_access": "Adiciona uma nova chave API para acesso programático.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Adicione uma nova chave API para acesso programático à <link href='https://pocket-id.org/docs/api'>API Pocket ID</link>.",
|
||||
"add_api_key": "Adicionar chave API",
|
||||
"manage_api_keys": "Gerenciar chaves API",
|
||||
"api_key_created": "Chave API criada",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Não tem dados de pré-visualização disponíveis",
|
||||
"copy_all": "Copiar tudo",
|
||||
"preview": "Pré-visualização",
|
||||
"preview_for_user": "Prévia de “ {name} ” ({email})",
|
||||
"preview_for_user": "Prévia de “ {name} ”",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Dá uma olhada nos dados OIDC que seriam enviados para esse usuário.",
|
||||
"show": "Mostrar",
|
||||
"select_an_option": "Escolha uma opção",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Nome de exibição",
|
||||
"configure_application_images": "Configurar imagens de aplicativos",
|
||||
"ui_config_disabled_info_title": "Configuração da interface do usuário desativada",
|
||||
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis."
|
||||
"ui_config_disabled_info_description": "A configuração da interface do usuário está desativada porque as configurações do aplicativo são gerenciadas por meio de variáveis de ambiente. Algumas configurações podem não ser editáveis.",
|
||||
"logo_from_url_description": "Cole uma URL direta da imagem (svg, png, webp). Encontre ícones em <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> ou <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "URL inválido",
|
||||
"require_user_email": "É preciso um endereço de e-mail",
|
||||
"require_user_email_description": "Pede que os usuários tenham um endereço de e-mail. Se desativado, os usuários sem endereço de e-mail não vão poder usar os recursos que precisam disso."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Воспользоваться пасскеем вместо этого?",
|
||||
"email_login": "Вход через электронную почту",
|
||||
"enter_a_login_code_to_sign_in": "Введите предварительно созданный код входа.",
|
||||
"sign_in_with_login_code": "Войти с кодом для входа",
|
||||
"request_a_login_code_via_email": "Запросить код входа на электронную почту.",
|
||||
"go_back": "Назад",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Письмо было отправлено на указанный адрес электронной почты, если он существует в системе.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Имя пасскея",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Назовите ваш пасскей, чтобы легко идентифицировать его позже.",
|
||||
"create_api_key": "Создать API ключ",
|
||||
"add_a_new_api_key_for_programmatic_access": "Добавить новый API ключ для программного доступа.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Добавь новый ключ API для программного доступа к <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Добавить API ключ",
|
||||
"manage_api_keys": "Управление API ключами",
|
||||
"api_key_created": "API ключ создан",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Предварительный просмотр данных не доступен",
|
||||
"copy_all": "Копировать все",
|
||||
"preview": "Предпросмотр",
|
||||
"preview_for_user": "Предпросмотр для {name} ({email})",
|
||||
"preview_for_user": "Предпросмотр для {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Предпросмотр данных OIDC, которые будут отправлены для этого пользователя",
|
||||
"show": "Показать",
|
||||
"select_an_option": "Выберите опцию",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Отображаемое имя",
|
||||
"configure_application_images": "Настройка изображений приложения",
|
||||
"ui_config_disabled_info_title": "Конфигурация пользовательского интерфейса отключена",
|
||||
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования."
|
||||
"ui_config_disabled_info_description": "Конфигурация пользовательского интерфейса отключена, потому что настройки приложения управляются через переменные среды. Некоторые настройки могут быть недоступны для редактирования.",
|
||||
"logo_from_url_description": "Вставьте прямой URL-адрес изображения (svg, png, webp). Ищите иконки на <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> или <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Недопустимый URL",
|
||||
"require_user_email": "Требовать адрес электронной почты",
|
||||
"require_user_email_description": "Требует, чтобы у пользователей был адрес электронной почты. Если эта функция отключена, пользователи без адреса электронной почты не смогут пользоваться функциями, для которых нужен адрес электронной почты."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Använd din passkey istället?",
|
||||
"email_login": "E-postinloggning",
|
||||
"enter_a_login_code_to_sign_in": "Ange en inloggningskod för att logga in.",
|
||||
"sign_in_with_login_code": "Logga in med inloggningskod",
|
||||
"request_a_login_code_via_email": "Begär en inloggningskod via e-post.",
|
||||
"go_back": "Gå tillbaka",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Om adressen finns i systemet har ett e-postmeddelande skickats dit.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Namn på Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Namnge din passkey för att enkelt kunna identifiera den senare.",
|
||||
"create_api_key": "Skapa API-nyckel",
|
||||
"add_a_new_api_key_for_programmatic_access": "Lägg till en ny API-nyckel för programmatisk åtkomst.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Lägg till en ny API-nyckel för programmatisk åtkomst till <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Lägg till API-nyckel",
|
||||
"manage_api_keys": "Hantera API-nycklar",
|
||||
"api_key_created": "API-nyckel skapad",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Inga förhandsgranskningsdata tillgängliga",
|
||||
"copy_all": "Kopiera allt",
|
||||
"preview": "Förhandsgranska",
|
||||
"preview_for_user": "Förhandsgranskning för {name} ({email})",
|
||||
"preview_for_user": "Förhandsgranskning för {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Förhandsgranska OIDC-data som skulle skickas för denna användare",
|
||||
"show": "Visa",
|
||||
"select_an_option": "Välj ett alternativ",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Visningsnamn",
|
||||
"configure_application_images": "Konfigurera applikationsbilder",
|
||||
"ui_config_disabled_info_title": "UI-konfiguration inaktiverad",
|
||||
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras."
|
||||
"ui_config_disabled_info_description": "UI-konfigurationen är inaktiverad eftersom applikationens konfigurationsinställningar hanteras via miljövariabler. Vissa inställningar kan inte redigeras.",
|
||||
"logo_from_url_description": "Klistra in en direkt bild-URL (svg, png, webp). Hitta ikoner på <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> eller <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Ogiltig URL",
|
||||
"require_user_email": "Kräver e-postadress",
|
||||
"require_user_email_description": "Kräver att användarna har en e-postadress. Om funktionen är inaktiverad kan användare utan e-postadress inte använda funktioner som kräver en e-postadress."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Використати ключ доступу натомість?",
|
||||
"email_login": "Вхід за електронною поштою",
|
||||
"enter_a_login_code_to_sign_in": "Введіть код для входу, щоб увійти.",
|
||||
"sign_in_with_login_code": "Увійдіть за допомогою коду для входу",
|
||||
"request_a_login_code_via_email": "Запросити код для входу електронною поштою.",
|
||||
"go_back": "Назад",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Електронний лист було надіслано на вказану електронну адресу, якщо вона існує в системі.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Назва ключа доступу",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Назвіть свій ключ доступу, щоб легко пізнати його пізніше.",
|
||||
"create_api_key": "Створити API-ключ",
|
||||
"add_a_new_api_key_for_programmatic_access": "Додайте новий API-ключ для програмного доступу.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Додайте новий ключ API для програмного доступу до <link href='https://pocket-id.org/docs/api'>API Pocket ID</link>.",
|
||||
"add_api_key": "Додати API-ключ",
|
||||
"manage_api_keys": "Керувати ключами API",
|
||||
"api_key_created": "Створено API-ключ",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "Попередній перегляд даних недоступний",
|
||||
"copy_all": "Скопіювати все",
|
||||
"preview": "Попередній перегляд",
|
||||
"preview_for_user": "Попередній перегляд для {name} ({email})",
|
||||
"preview_for_user": "Попередній перегляд для {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Попередній перегляд OIDC-даних для цього користувача",
|
||||
"show": "Показати",
|
||||
"select_an_option": "Обрати варіант",
|
||||
@@ -444,11 +445,15 @@
|
||||
"invalid_client_id": "Ідентифікатор клієнта може містити тільки літери, цифри, підкреслення та дефіси.",
|
||||
"custom_client_id_description": "Встановіть власний ідентифікатор клієнта, якщо це потрібно для вашої програми. В іншому випадку залиште поле порожнім, щоб створити випадковий ідентифікатор.",
|
||||
"generated": "Створено",
|
||||
"administration": "Адміністрація",
|
||||
"administration": "Адміністрування",
|
||||
"group_rdn_attribute_description": "Атрибут, що використовується в розрізнювальному імені групи (DN).",
|
||||
"display_name_attribute": "Атрибут імені для відображення",
|
||||
"display_name": "Ім'я для відображення",
|
||||
"configure_application_images": "Налаштування зображень додатків",
|
||||
"ui_config_disabled_info_title": "Конфігурація інтерфейсу користувача вимкнена",
|
||||
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування."
|
||||
"ui_config_disabled_info_description": "Конфігурація інтерфейсу користувача вимкнена, оскільки налаштування конфігурації програми керуються через змінні середовища. Деякі налаштування можуть бути недоступними для редагування.",
|
||||
"logo_from_url_description": "Вставте прямий URL-адресу зображення (svg, png, webp). Знайдіть іконки на <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> або <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Недійсний URL-адреса",
|
||||
"require_user_email": "Потрібна адреса електронної пошти",
|
||||
"require_user_email_description": "Вимагає від користувачів наявність адреси електронної пошти. Якщо ця опція вимкнена, користувачі без адреси електронної пошти не зможуть користуватися функціями, для яких потрібна адреса електронної пошти."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "Sử dụng passkey?",
|
||||
"email_login": "Đăng nhập bằng email",
|
||||
"enter_a_login_code_to_sign_in": "Nhập mã đăng nhập để đăng nhập.",
|
||||
"sign_in_with_login_code": "Đăng nhập bằng mã đăng nhập",
|
||||
"request_a_login_code_via_email": "Yêu cầu mã đăng nhập qua email.",
|
||||
"go_back": "Quay lại",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Một email đã được gửi đến địa chỉ email đã cung cấp, nếu địa chỉ đó tồn tại trong hệ thống.",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "Tên Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Đặt tên cho passkey để dễ dàng nhận diện sau này.",
|
||||
"create_api_key": "Tạo API Key",
|
||||
"add_a_new_api_key_for_programmatic_access": "Thêm API Key mới cho truy cập lập trình.",
|
||||
"add_a_new_api_key_for_programmatic_access": "Thêm một khóa API mới để truy cập chương trình vào <link href='https://pocket-id.org/docs/api'>API Pocket ID</link>.",
|
||||
"add_api_key": "Thêm API Key",
|
||||
"manage_api_keys": "Quản lý API keys",
|
||||
"api_key_created": "API Key đã được tạo",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "No preview data available",
|
||||
"copy_all": "Sao chép tất cả",
|
||||
"preview": "Xem trước",
|
||||
"preview_for_user": "Xem trước cho {name} ({email})",
|
||||
"preview_for_user": "Xem trước cho {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Xem trước dữ liệu OIDC sẽ được gửi cho người dùng này",
|
||||
"show": "Hiển thị",
|
||||
"select_an_option": "Chọn một tùy chọn",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "Tên hiển thị",
|
||||
"configure_application_images": "Cấu hình hình ảnh ứng dụng",
|
||||
"ui_config_disabled_info_title": "Cấu hình giao diện người dùng đã bị vô hiệu hóa",
|
||||
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa."
|
||||
"ui_config_disabled_info_description": "Cấu hình giao diện người dùng (UI) đã bị vô hiệu hóa vì các thiết lập cấu hình ứng dụng được quản lý thông qua biến môi trường. Một số thiết lập có thể không thể chỉnh sửa.",
|
||||
"logo_from_url_description": "Dán URL hình ảnh trực tiếp (svg, png, webp). Tìm biểu tượng tại <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> hoặc <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "URL không hợp lệ",
|
||||
"require_user_email": "Yêu cầu địa chỉ email",
|
||||
"require_user_email_description": "Yêu cầu người dùng phải có địa chỉ email. Nếu tính năng này bị vô hiệu hóa, những người dùng không có địa chỉ email sẽ không thể sử dụng các tính năng yêu cầu địa chỉ email."
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "改用您的通行密钥?",
|
||||
"email_login": "电子邮件登录",
|
||||
"enter_a_login_code_to_sign_in": "输入一次性登录码以登录。",
|
||||
"sign_in_with_login_code": "使用登录码登录",
|
||||
"request_a_login_code_via_email": "通过电子邮件请求登录代码。",
|
||||
"go_back": "返回",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "如果系统中存在提供的电子邮件地址,则已发送一封电子邮件。",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "重命名通行密钥",
|
||||
"name_your_passkey_to_easily_identify_it_later": "为您的通行密钥命名,以便以后轻松识别。",
|
||||
"create_api_key": "创建 API 密钥",
|
||||
"add_a_new_api_key_for_programmatic_access": "添加一个新的 API 密钥用于编程访问。",
|
||||
"add_a_new_api_key_for_programmatic_access": "为程序化访问<link href='https://pocket-id.org/docs/api'>Pocket ID API</link> 添加新的 API 密钥。",
|
||||
"add_api_key": "添加 API 密钥",
|
||||
"manage_api_keys": "管理 API 密钥",
|
||||
"api_key_created": "API 密钥已创建",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "暂无可用的预览数据",
|
||||
"copy_all": "全部复制",
|
||||
"preview": "预览",
|
||||
"preview_for_user": "为 {name} ({email}) 预览",
|
||||
"preview_for_user": "为 {name} 预览",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "预览将为此用户发送的 OIDC 数据",
|
||||
"show": "显示",
|
||||
"select_an_option": "请选择",
|
||||
@@ -392,7 +393,7 @@
|
||||
"signup": "注册",
|
||||
"user_creation": "用户创建",
|
||||
"configure_user_creation": "管理用户创建设置,包括注册方式和新用户的默认权限。",
|
||||
"user_creation_groups_description": "在用户注册时自动将这些组分配给新用户。",
|
||||
"user_creation_groups_description": "新用户注册时自动分配的群组。",
|
||||
"user_creation_claims_description": "在用户注册时自动为新用户分配这些自定义声明。",
|
||||
"user_creation_updated_successfully": "用户创建设置已成功更新。",
|
||||
"signup_disabled_description": "已完全禁止新用户注册。只有管理员可以创建新账户。",
|
||||
@@ -430,9 +431,9 @@
|
||||
"of": "中的",
|
||||
"skip_passkey_setup": "跳过设置通行密钥",
|
||||
"skip_passkey_setup_description": "强烈建议设置一个通行密钥,否则您在此会话结束后将无法访问您的账户。",
|
||||
"my_apps": "我的应用程序",
|
||||
"no_apps_available": "没有可用应用程序",
|
||||
"contact_your_administrator_for_app_access": "请联系您的管理员以获取应用程序的访问权限。",
|
||||
"my_apps": "我的应用",
|
||||
"no_apps_available": "没有可用应用",
|
||||
"contact_your_administrator_for_app_access": "请联系您的管理员以获取应用的访问权限。",
|
||||
"launch": "启动",
|
||||
"client_launch_url": "客户发布链接",
|
||||
"client_launch_url_description": "当用户从“我的应用”页面启动应用时将打开的 URL。",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "显示名称",
|
||||
"configure_application_images": "配置应用程序图标",
|
||||
"ui_config_disabled_info_title": "用户界面配置已禁用",
|
||||
"ui_config_disabled_info_description": "用户界面配置已禁用,因为应用程序配置设置通过环境变量进行管理。某些设置可能无法编辑。"
|
||||
"ui_config_disabled_info_description": "由于应用配置设置已通过环境变量设定,用户界面配置已禁用。某些设置可能无法编辑。",
|
||||
"logo_from_url_description": "粘贴直接图片URL(svg、png、webp格式)。<link href=\"https://selfh.st/icons\">可在Selfh.st图标库</link>或<link href=\"https://dashboardicons.com\">仪表盘图标库</link>中查找图标。",
|
||||
"invalid_url": "无效网址",
|
||||
"require_user_email": "需要电子邮件地址",
|
||||
"require_user_email_description": "要求用户拥有电子邮件地址。若禁用此功能,没有电子邮件地址的用户将无法使用需要电子邮件地址的功能。"
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
"use_your_passkey_instead": "改為使用您的密碼金鑰?",
|
||||
"email_login": "電子郵件登入",
|
||||
"enter_a_login_code_to_sign_in": "輸入登入代碼以登入。",
|
||||
"sign_in_with_login_code": "使用登入代碼登入",
|
||||
"request_a_login_code_via_email": "透過電子郵件取得登入代碼。",
|
||||
"go_back": "返回",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "如果該電子郵件地址存在於系統中,我們會發送信件至您所提供的電子信箱。",
|
||||
@@ -134,7 +135,7 @@
|
||||
"name_passkey": "命名密碼金鑰",
|
||||
"name_your_passkey_to_easily_identify_it_later": "命名您的密碼金鑰以便日後辨識。",
|
||||
"create_api_key": "建立 API 金鑰",
|
||||
"add_a_new_api_key_for_programmatic_access": "新增 API 金鑰以供程式化存取。",
|
||||
"add_a_new_api_key_for_programmatic_access": "新增一個 API 金鑰,以程式化方式存取<link href='https://pocket-id.org/docs/api'>Pocket ID API</link>。",
|
||||
"add_api_key": "新增 API 金鑰",
|
||||
"manage_api_keys": "管理 API 金鑰",
|
||||
"api_key_created": "已建立 API 金鑰",
|
||||
@@ -372,7 +373,7 @@
|
||||
"no_preview_data_available": "無預覽資料",
|
||||
"copy_all": "全部複製",
|
||||
"preview": "預覽",
|
||||
"preview_for_user": "預覽 {name} ({email})",
|
||||
"preview_for_user": "預覽 {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "預覽將為此使用者傳送的 OIDC 資料",
|
||||
"show": "顯示",
|
||||
"select_an_option": "選擇一個選項",
|
||||
@@ -450,5 +451,9 @@
|
||||
"display_name": "顯示名稱",
|
||||
"configure_application_images": "設定應用程式映像檔",
|
||||
"ui_config_disabled_info_title": "使用者介面設定已停用",
|
||||
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。"
|
||||
"ui_config_disabled_info_description": "使用者介面設定已停用,因為應用程式的設定參數是透過環境變數進行管理。部分設定可能無法編輯。",
|
||||
"logo_from_url_description": "貼上直接圖片網址(svg、png、webp)。在<link href=\"https://selfh.st/icons\">Selfh.st 圖示庫或</link> <link href=\"https://dashboardicons.com\">儀表板圖示庫中</link>尋找圖示。",
|
||||
"invalid_url": "無效網址",
|
||||
"require_user_email": "需要電子郵件地址",
|
||||
"require_user_email_description": "要求使用者必須擁有電子郵件地址。若此功能被停用,沒有電子郵件地址的使用者將無法使用需要電子郵件地址的功能。"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "1.11.2",
|
||||
"version": "1.13.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -53,9 +53,15 @@
|
||||
<Table.Cell>
|
||||
<Badge class="rounded-full" variant="outline">{translateAuditLogEvent(item.event)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
>{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell
|
||||
>
|
||||
<Table.Cell>
|
||||
{#if item.city && item.country}
|
||||
{item.city}, {item.country}
|
||||
{:else if item.country}
|
||||
{item.country}
|
||||
{:else}
|
||||
{m.unknown()}
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.ipAddress}</Table.Cell>
|
||||
<Table.Cell>{item.device}</Table.Cell>
|
||||
<Table.Cell>{item.data.clientName}</Table.Cell>
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
<div {...restProps}>
|
||||
{#if label}
|
||||
<Label class="mb-0" for={id}>{label}</Label>
|
||||
<Label required={input?.required} class="mb-0" for={id}>{label}</Label>
|
||||
{/if}
|
||||
{#if description}
|
||||
<p class="text-muted-foreground mt-1 text-xs">
|
||||
|
||||
@@ -35,12 +35,7 @@
|
||||
|
||||
isLoading = true;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
imageDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
imageDataURL = URL.createObjectURL(file);
|
||||
await updateCallback(file).catch(() => {
|
||||
imageDataURL = cachedProfilePicture.getUrl(userId);
|
||||
});
|
||||
|
||||
85
frontend/src/lib/components/form/url-file-input.svelte
Normal file
85
frontend/src/lib/components/form/url-file-input.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||
import { Button, buttonVariants } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideChevronDown } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
label,
|
||||
accept,
|
||||
onchange
|
||||
}: {
|
||||
label: string;
|
||||
accept?: string;
|
||||
onchange: (file: File | string | null) => void;
|
||||
} = $props();
|
||||
|
||||
let url = $state('');
|
||||
let hasError = $state(false);
|
||||
|
||||
async function handleFileChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
url = '';
|
||||
hasError = false;
|
||||
onchange(file);
|
||||
}
|
||||
|
||||
async function handleUrlChange(e: Event) {
|
||||
const url = (e.target as HTMLInputElement).value.trim();
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
hasError = false;
|
||||
} catch {
|
||||
hasError = true;
|
||||
return;
|
||||
}
|
||||
|
||||
onchange(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
{accept}
|
||||
onchange={handleFileChange}
|
||||
onclick={(e: any) => (e.target.value = '')}
|
||||
>
|
||||
<Button variant="secondary" class="rounded-r-none">
|
||||
{label}
|
||||
</Button>
|
||||
</FileInput>
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
class={cn(buttonVariants({ variant: 'secondary' }), 'rounded-l-none border-l')}
|
||||
>
|
||||
<LucideChevronDown class="size-4" /></Popover.Trigger
|
||||
>
|
||||
<Popover.Content class="w-80">
|
||||
<Label for="file-url" class="text-xs">URL</Label>
|
||||
<Input
|
||||
id="file-url"
|
||||
placeholder=""
|
||||
value={url}
|
||||
oninput={(e) => (url = e.currentTarget.value)}
|
||||
onfocusout={handleUrlChange}
|
||||
aria-invalid={hasError}
|
||||
/>
|
||||
{#if hasError}
|
||||
<p class="text-destructive mt-1 text-start text-xs">{m.invalid_url()}</p>
|
||||
{/if}
|
||||
|
||||
<p class="text-muted-foreground mt-2 text-xs">
|
||||
<FormattedMessage m={m.logo_from_url_description()} />
|
||||
</p>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
@@ -26,8 +26,7 @@
|
||||
<DropdownMenu.Label class="font-normal">
|
||||
<div class="flex flex-col space-y-1">
|
||||
<p class="text-sm leading-none font-medium">
|
||||
{$userStore?.firstName}
|
||||
{$userStore?.lastName}
|
||||
{$userStore?.displayName}
|
||||
</p>
|
||||
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { LucideImageOff } from '@lucide/svelte';
|
||||
import type { HTMLImgAttributes } from 'svelte/elements';
|
||||
|
||||
let props: HTMLImgAttributes & {} = $props();
|
||||
let error = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
props.src;
|
||||
error = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class={'bg-muted flex items-center justify-center rounded-2xl p-3'}>
|
||||
<img class={cn('size-24 object-contain', props.class)} {...props} />
|
||||
{#if error}
|
||||
<LucideImageOff class={cn('text-muted-foreground p-5', props.class)} />
|
||||
{:else}
|
||||
<img
|
||||
{...props}
|
||||
class={cn('object-contain aspect-square', props.class)}
|
||||
onerror={() => (error = true)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import { cachedBackgroundImage } from '$lib/utils/cached-image-util';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import type { Snippet } from 'svelte';
|
||||
@@ -18,6 +19,24 @@
|
||||
} = $props();
|
||||
|
||||
const isDesktop = new MediaQuery('min-width: 1024px');
|
||||
let alternativeSignInButton = $state({
|
||||
href: '/login/alternative',
|
||||
label: m.alternative_sign_in_methods()
|
||||
});
|
||||
|
||||
appConfigStore.subscribe((config) => {
|
||||
if (config.emailOneTimeAccessAsUnauthenticatedEnabled) {
|
||||
alternativeSignInButton.href = '/login/alternative';
|
||||
alternativeSignInButton.label = m.alternative_sign_in_methods();
|
||||
} else {
|
||||
alternativeSignInButton.href = '/login/alternative/code';
|
||||
alternativeSignInButton.label = m.sign_in_with_login_code();
|
||||
}
|
||||
|
||||
if (page.url.pathname != '/login') {
|
||||
alternativeSignInButton.href = `${alternativeSignInButton.href}?redirect=${encodeURIComponent(page.url.pathname + page.url.search)}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isDesktop.current}
|
||||
@@ -38,14 +57,10 @@
|
||||
style={animate ? 'animation-delay: 500ms;' : ''}
|
||||
>
|
||||
<a
|
||||
href={page.url.pathname == '/login'
|
||||
? '/login/alternative'
|
||||
: `/login/alternative?redirect=${encodeURIComponent(
|
||||
page.url.pathname + page.url.search
|
||||
)}`}
|
||||
href={alternativeSignInButton.href}
|
||||
class="text-muted-foreground text-xs transition-colors hover:underline"
|
||||
>
|
||||
{m.alternative_sign_in_methods()}
|
||||
{alternativeSignInButton.label}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -75,14 +90,10 @@
|
||||
{@render children()}
|
||||
{#if showAlternativeSignInMethodButton}
|
||||
<a
|
||||
href={page.url.pathname == '/login'
|
||||
? '/login/alternative'
|
||||
: `/login/alternative?redirect=${encodeURIComponent(
|
||||
page.url.pathname + page.url.search
|
||||
)}`}
|
||||
href={alternativeSignInButton.href}
|
||||
class="text-muted-foreground mt-7 flex justify-center text-xs transition-colors hover:underline"
|
||||
>
|
||||
{m.alternative_sign_in_methods()}
|
||||
{alternativeSignInButton.label}
|
||||
</a>
|
||||
{/if}
|
||||
</Card.CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { cachedApplicationLogo } from '$lib/utils/cached-image-util';
|
||||
import { cn } from '$lib/utils/style';
|
||||
import { mode } from 'mode-watcher';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
@@ -9,4 +10,9 @@
|
||||
const isLightMode = $derived(mode.current === 'light');
|
||||
</script>
|
||||
|
||||
<img {...props} src={cachedApplicationLogo.getUrl(isLightMode)} alt={m.logo()} />
|
||||
<img
|
||||
{...props}
|
||||
class={cn('aspect-square object-contain', props.class)}
|
||||
src={cachedApplicationLogo.getUrl(isLightMode)}
|
||||
alt={m.logo()}
|
||||
/>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { UserSignUp } from '$lib/types/user.type';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { tryCatch } from '$lib/utils/try-catch-util';
|
||||
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||
import { get } from 'svelte/store';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
@@ -27,7 +29,7 @@
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||
username: usernameSchema,
|
||||
email: z.email()
|
||||
email: get(appConfigStore).requireUserEmail ? z.email() : emptyToUndefined(z.email().optional())
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
|
||||
@@ -5,16 +5,25 @@
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
required = false,
|
||||
children,
|
||||
...restProps
|
||||
}: LabelPrimitive.RootProps = $props();
|
||||
}: LabelPrimitive.RootProps & {
|
||||
required?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<LabelPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="label"
|
||||
class={cn(
|
||||
'mb-3 flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
'mb-3 flex items-center gap-1.5 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
required && "after:text-[14px] after:text-red-500 after:content-['*']",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</LabelPrimitive.Root>
|
||||
|
||||
@@ -10,6 +10,7 @@ export type AppConfig = {
|
||||
disableAnimations: boolean;
|
||||
uiConfigDisabled: boolean;
|
||||
accentColor: string;
|
||||
requireUserEmail: boolean;
|
||||
};
|
||||
|
||||
export type AllAppConfig = AppConfig & {
|
||||
|
||||
@@ -46,7 +46,8 @@ export type OidcClientUpdateWithLogo = OidcClientUpdate & {
|
||||
};
|
||||
|
||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
logo: File | null | undefined;
|
||||
logo?: File | null;
|
||||
logoUrl?: string;
|
||||
};
|
||||
|
||||
export type OidcDeviceCodeInfo = {
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { UserGroup } from './user-group.type';
|
||||
export type User = {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
email: string | undefined;
|
||||
firstName: string;
|
||||
lastName?: string;
|
||||
displayName: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { z } from 'zod/v4';
|
||||
export type FormInput<T> = {
|
||||
value: T;
|
||||
error: string | null;
|
||||
required: boolean;
|
||||
};
|
||||
|
||||
type FormInputs<T> = {
|
||||
@@ -17,11 +18,18 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
|
||||
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
|
||||
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
|
||||
|
||||
const shape =
|
||||
schema instanceof z.ZodObject ? (schema.shape as Record<string, z.ZodTypeAny>) : {};
|
||||
|
||||
for (const key in initialValues) {
|
||||
if (Object.prototype.hasOwnProperty.call(initialValues, key)) {
|
||||
const fieldSchema = shape[key];
|
||||
|
||||
inputs[key as keyof z.infer<T>] = {
|
||||
value: initialValues[key as keyof z.infer<T>],
|
||||
error: null
|
||||
error: null,
|
||||
required: fieldSchema ? isRequired(fieldSchema) : false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -31,7 +39,6 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
function validate() {
|
||||
let success = true;
|
||||
inputsStore.update((inputs) => {
|
||||
// Extract values from inputs to validate against the schema
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(inputs).map(([key, input]) => [key, input.value])
|
||||
);
|
||||
@@ -54,7 +61,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
inputs[input as keyof z.infer<T>].error = null;
|
||||
}
|
||||
}
|
||||
// Update the input values with the parsed data
|
||||
|
||||
for (const key in result.data) {
|
||||
if (Object.prototype.hasOwnProperty.call(inputs, key)) {
|
||||
inputs[key as keyof z.infer<T>].value = result.data[key];
|
||||
@@ -82,7 +89,9 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
function reset() {
|
||||
inputsStore.update((inputs) => {
|
||||
for (const input of Object.keys(inputs)) {
|
||||
const current = inputs[input as keyof z.infer<T>];
|
||||
inputs[input as keyof z.infer<T>] = {
|
||||
...current,
|
||||
value: initialValues[input as keyof z.infer<T>],
|
||||
error: null
|
||||
};
|
||||
@@ -98,7 +107,6 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
});
|
||||
}
|
||||
|
||||
// Trims whitespace from string values and arrays of strings
|
||||
function trimValue(value: any) {
|
||||
if (typeof value === 'string') {
|
||||
value = value.trim();
|
||||
@@ -113,6 +121,26 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
|
||||
return value;
|
||||
}
|
||||
|
||||
function isRequired(fieldSchema: z.ZodTypeAny): boolean {
|
||||
// Handle unions like callbackUrlSchema
|
||||
if (fieldSchema instanceof z.ZodUnion) {
|
||||
return !fieldSchema.def.options.some((o: any) => {
|
||||
return o.def.type == 'optional';
|
||||
});
|
||||
}
|
||||
|
||||
// Handle pipes like emptyToUndefined
|
||||
if (fieldSchema instanceof z.ZodPipe) {
|
||||
return isRequired(fieldSchema.def.out as z.ZodTypeAny);
|
||||
}
|
||||
|
||||
// Handle the normal cases
|
||||
if (fieldSchema instanceof z.ZodOptional || fieldSchema instanceof z.ZodDefault) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return {
|
||||
schema,
|
||||
inputs: inputsStore,
|
||||
|
||||
@@ -14,9 +14,14 @@ export async function setLocale(locale: Locale, reload = true) {
|
||||
export async function setLocaleForLibraries(
|
||||
locale: Locale = (extractLocaleFromCookie() as Locale) || 'en'
|
||||
) {
|
||||
let dateFnsLocale: string = locale;
|
||||
if (dateFnsLocale === 'en') {
|
||||
dateFnsLocale = 'en-US'; // datefns doesn't have 'en'
|
||||
}
|
||||
|
||||
const [zodResult, dateFnsResult] = await Promise.allSettled([
|
||||
import(`../../../node_modules/zod/v4/locales/${locale}.js`),
|
||||
import(`../../../node_modules/date-fns/locale/${locale}.js`)
|
||||
import(`../../../node_modules/date-fns/locale/${dateFnsLocale}.js`)
|
||||
]);
|
||||
|
||||
if (zodResult.status === 'fulfilled') {
|
||||
|
||||
@@ -14,9 +14,11 @@ export const callbackUrlSchema = z
|
||||
.nonempty()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (val === '*') return true;
|
||||
if (val.includes('*')) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
new URL(val.replace(/\*/g, 'x'));
|
||||
new URL(val);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
{m.try_again()}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button onclick={() => history.back()} class="flex-1" variant="secondary">
|
||||
<Button href={document.referrer || '/'} class="flex-1" variant="secondary">
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
{:else if client.hasLogo}
|
||||
<img
|
||||
class="size-10"
|
||||
class="size-10 aspect-square object-contain"
|
||||
src={cachedOidcClientLogo.getUrl(client.id)}
|
||||
draggable={false}
|
||||
alt={m.client_logo()}
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import { fade } from 'svelte/transition';
|
||||
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -24,7 +27,7 @@
|
||||
const user = await webauthnService.finishLogin(authResponse);
|
||||
|
||||
await userStore.setUser(user);
|
||||
goto('/settings');
|
||||
goto(data.redirect || '/settings');
|
||||
} catch (e) {
|
||||
error = getWebauthnErrorMessage(e);
|
||||
}
|
||||
|
||||
7
frontend/src/routes/login/+page.ts
Normal file
7
frontend/src/routes/login/+page.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
export const load: PageLoad = async ({ url }) => {
|
||||
return {
|
||||
redirect: url.searchParams.get('redirect') || '/settings'
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { afterNavigate, goto } from '$app/navigation';
|
||||
import SignInWrapper from '$lib/components/login-wrapper.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Input from '$lib/components/ui/input/input.svelte';
|
||||
@@ -16,9 +15,17 @@
|
||||
let code = $state(data.code ?? '');
|
||||
let isLoading = $state(false);
|
||||
let error: string | undefined = $state();
|
||||
let backHref = $state('/login/alternative');
|
||||
|
||||
const userService = new UserService();
|
||||
|
||||
// If the previous page is a Pocket ID page, go back there instead of the generic alternative login page
|
||||
afterNavigate((e) => {
|
||||
if (e.from?.url.pathname) {
|
||||
backHref = e.from.url.pathname + e.from.url.search;
|
||||
}
|
||||
});
|
||||
|
||||
async function authenticate() {
|
||||
isLoading = true;
|
||||
try {
|
||||
@@ -63,9 +70,7 @@
|
||||
<form onsubmit={preventDefault(authenticate)} class="w-full max-w-[450px]">
|
||||
<Input id="Code" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
|
||||
<div class="mt-8 flex justify-between gap-2">
|
||||
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
|
||||
>{m.go_back()}</Button
|
||||
>
|
||||
<Button variant="secondary" class="flex-1" href={backHref}>{m.go_back()}</Button>
|
||||
<Button class="flex-1" type="submit" {isLoading}>{m.submit()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import UserService from '$lib/services/user-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { UserCreate } from '$lib/types/user.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { get } from 'svelte/store';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
@@ -34,9 +36,11 @@
|
||||
const formSchema = z.object({
|
||||
firstName: z.string().min(1).max(50),
|
||||
lastName: emptyToUndefined(z.string().max(50).optional()),
|
||||
displayName: z.string().max(100),
|
||||
displayName: z.string().min(1).max(100),
|
||||
username: usernameSchema,
|
||||
email: z.email(),
|
||||
email: get(appConfigStore).requireUserEmail
|
||||
? z.email()
|
||||
: emptyToUndefined(z.email().optional()),
|
||||
isAdmin: z.boolean()
|
||||
});
|
||||
type FormSchema = typeof formSchema;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import FormattedMessage from '$lib/components/formatted-message.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
@@ -48,7 +49,11 @@
|
||||
<ShieldPlus class="text-primary/80 size-5" />
|
||||
{m.create_api_key()}
|
||||
</Card.Title>
|
||||
<Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
|
||||
<Card.Description
|
||||
><FormattedMessage
|
||||
m={m.add_a_new_api_key_for_programmatic_access()}
|
||||
/></Card.Description
|
||||
>
|
||||
</div>
|
||||
{#if !expandAddApiKey}
|
||||
<Button onclick={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
|
||||
|
||||
@@ -31,12 +31,7 @@
|
||||
if (!file) return;
|
||||
|
||||
image = file;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
imageDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
imageDataURL = URL.createObjectURL(file);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
@@ -32,6 +32,7 @@
|
||||
let isSendingTestEmail = $state(false);
|
||||
|
||||
const formSchema = z.object({
|
||||
requireUserEmail: z.boolean(),
|
||||
smtpHost: z.string().min(1),
|
||||
smtpPort: z.number().min(1),
|
||||
smtpUser: z.string(),
|
||||
@@ -97,7 +98,14 @@
|
||||
|
||||
<form onsubmit={preventDefault(onSubmit)}>
|
||||
<fieldset disabled={$appConfigStore.uiConfigDisabled}>
|
||||
<h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4>
|
||||
<h4 class="mb-4 text-lg font-semibold">{m.general()}</h4>
|
||||
<SwitchWithLabel
|
||||
id="require-user-email"
|
||||
label={m.require_user_email()}
|
||||
description={m.require_user_email_description()}
|
||||
bind:checked={$inputs.requireUserEmail.value}
|
||||
/>
|
||||
<h4 class="mt-10 text-lg font-semibold">{m.smtp_configuration()}</h4>
|
||||
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
|
||||
<FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
|
||||
<FormInput label={m.smtp_port()} type="number" bind:input={$inputs.smtpPort} />
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user