mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-02-20 03:39:52 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9839a978c | ||
|
|
b81de45166 | ||
|
|
22f4254932 | ||
|
|
507f9490fa | ||
|
|
043cce615d | ||
|
|
69e2083722 | ||
|
|
d47b20326f | ||
|
|
fc9939d1f1 | ||
|
|
2c1c67b5e4 | ||
|
|
d010be4c88 | ||
|
|
01db8c0a46 | ||
|
|
fe5917d96d | ||
|
|
4f0b434c54 | ||
|
|
6bdf5fa37a | ||
|
|
47bd5ba1ba | ||
|
|
b746ac0835 | ||
|
|
79989fb176 | ||
|
|
ecc7e224e9 | ||
|
|
549d219f44 | ||
|
|
ffe18db2fb | ||
|
|
e8b172f1c3 |
835
CHANGELOG.md
835
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",
|
||||
|
||||
@@ -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,9 +8,12 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
@@ -66,6 +69,7 @@ func NewOidcService(
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
webAuthnService *WebAuthnService,
|
||||
httpClient *http.Client,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
@@ -74,6 +78,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 +474,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 +719,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 +732,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 +743,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 +758,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 +901,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 +932,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 +1325,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 +1460,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 +1881,87 @@ 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{}
|
||||
}
|
||||
|
||||
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + 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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "입력한 이메일 주소가 시스템에 존재하는 경우 이메일이 발송됩니다.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "Письмо было отправлено на указанный адрес электронной почты, если он существует в системе.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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": "Електронний лист було надіслано на вказану електронну адресу, якщо вона існує в системі.",
|
||||
@@ -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,13 @@
|
||||
"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-адреса"
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
@@ -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,7 @@
|
||||
"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ệ"
|
||||
}
|
||||
|
||||
@@ -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": "如果系统中存在提供的电子邮件地址,则已发送一封电子邮件。",
|
||||
@@ -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,7 @@
|
||||
"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": "无效网址"
|
||||
}
|
||||
|
||||
@@ -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": "如果該電子郵件地址存在於系統中,我們會發送信件至您所提供的電子信箱。",
|
||||
@@ -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,7 @@
|
||||
"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": "無效網址"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "1.11.2",
|
||||
"version": "1.12.0",
|
||||
"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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
accentColor: z.string()
|
||||
});
|
||||
|
||||
let { inputs, ...form } = $derived(createForm(formSchema, appConfig));
|
||||
let { inputs, ...form } = $derived(createForm(formSchema, updatedAppConfig));
|
||||
|
||||
async function onSubmit() {
|
||||
const data = form.validate();
|
||||
@@ -69,7 +69,6 @@
|
||||
description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
|
||||
bind:checked={$inputs.allowOwnAccountEdit.value}
|
||||
/>
|
||||
|
||||
<SwitchWithLabel
|
||||
id="emails-verified"
|
||||
label={m.emails_verified()}
|
||||
|
||||
@@ -37,13 +37,13 @@
|
||||
ldapAttributeUserUsername: z.string().min(1),
|
||||
ldapAttributeUserEmail: z.string().min(1),
|
||||
ldapAttributeUserFirstName: z.string().min(1),
|
||||
ldapAttributeUserLastName: z.string().min(1),
|
||||
ldapAttributeUserDisplayName: z.string().min(1),
|
||||
ldapAttributeUserProfilePicture: z.string(),
|
||||
ldapAttributeGroupMember: z.string(),
|
||||
ldapAttributeUserLastName: z.string().optional(),
|
||||
ldapAttributeUserDisplayName: z.string().optional(),
|
||||
ldapAttributeUserProfilePicture: z.string().optional(),
|
||||
ldapAttributeGroupMember: z.string().optional(),
|
||||
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
||||
ldapAttributeGroupName: z.string().min(1),
|
||||
ldapAttributeAdminGroup: z.string(),
|
||||
ldapAttributeAdminGroup: z.string().optional(),
|
||||
ldapSoftDeleteUsers: z.boolean()
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
import { LucideChevronLeft, LucideRefreshCcw, RectangleEllipsis } from '@lucide/svelte';
|
||||
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
@@ -30,12 +30,12 @@
|
||||
const oidcService = new OidcService();
|
||||
|
||||
const setupDetails = $state({
|
||||
[m.authorization_url()]: `https://${page.url.hostname}/authorize`,
|
||||
[m.oidc_discovery_url()]: `https://${page.url.hostname}/.well-known/openid-configuration`,
|
||||
[m.token_url()]: `https://${page.url.hostname}/api/oidc/token`,
|
||||
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
|
||||
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
|
||||
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
|
||||
[m.authorization_url()]: `https://${page.url.host}/authorize`,
|
||||
[m.oidc_discovery_url()]: `https://${page.url.host}/.well-known/openid-configuration`,
|
||||
[m.token_url()]: `https://${page.url.host}/api/oidc/token`,
|
||||
[m.userinfo_url()]: `https://${page.url.host}/api/oidc/userinfo`,
|
||||
[m.logout_url()]: `https://${page.url.host}/api/oidc/end-session`,
|
||||
[m.certificate_url()]: `https://${page.url.host}/.well-known/jwks.json`,
|
||||
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
|
||||
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
|
||||
});
|
||||
@@ -97,12 +97,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
let previewUserId = $state<string | null>(null);
|
||||
|
||||
function handlePreview(userId: string) {
|
||||
previewUserId = userId;
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
clientSecretStore.clear();
|
||||
});
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<Label for="issuer-{i}" class="text-xs">Issuer (Required)</Label>
|
||||
<Label required for="issuer-{i}" class="text-xs">Issuer</Label>
|
||||
<Input
|
||||
id="issuer-{i}"
|
||||
placeholder="https://example.com/"
|
||||
@@ -96,10 +96,10 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="subject-{i}" class="text-xs">Subject (Optional)</Label>
|
||||
<Label for="subject-{i}" class="text-xs">Subject</Label>
|
||||
<Input
|
||||
id="subject-{i}"
|
||||
placeholder="Defaults to the client ID: {client?.id}"
|
||||
placeholder="Defaults to the client ID"
|
||||
value={identity.subject || ''}
|
||||
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
|
||||
aria-invalid={!!getFieldError(i, 'subject')}
|
||||
@@ -110,7 +110,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="audience-{i}" class="text-xs">Audience (Optional)</Label>
|
||||
<Label for="audience-{i}" class="text-xs">Audience</Label>
|
||||
<Input
|
||||
id="audience-{i}"
|
||||
placeholder="Defaults to the Pocket ID URL"
|
||||
@@ -124,7 +124,7 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label for="jwks-{i}" class="text-xs">JWKS URL (Optional)</Label>
|
||||
<Label for="jwks-{i}" class="text-xs">JWKS URL</Label>
|
||||
<Input
|
||||
id="jwks-{i}"
|
||||
placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
<script lang="ts">
|
||||
import FileInput from '$lib/components/form/file-input.svelte';
|
||||
import FormInput from '$lib/components/form/form-input.svelte';
|
||||
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import type {
|
||||
OidcClient,
|
||||
@@ -21,6 +18,7 @@
|
||||
import { z } from 'zod/v4';
|
||||
import FederatedIdentitiesInput from './federated-identities-input.svelte';
|
||||
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
|
||||
import OidcClientImageInput from './oidc-client-image-input.svelte';
|
||||
|
||||
let {
|
||||
callback,
|
||||
@@ -31,7 +29,6 @@
|
||||
callback: (client: OidcClientCreateWithLogo | OidcClientUpdateWithLogo) => Promise<boolean>;
|
||||
mode: 'create' | 'update';
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let showAdvancedOptions = $state(false);
|
||||
let logo = $state<File | null | undefined>();
|
||||
@@ -50,7 +47,8 @@
|
||||
launchURL: existingClient?.launchURL || '',
|
||||
credentials: {
|
||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||
}
|
||||
},
|
||||
logoUrl: ''
|
||||
};
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -71,6 +69,7 @@
|
||||
pkceEnabled: z.boolean(),
|
||||
requiresReauthentication: z.boolean(),
|
||||
launchURL: optionalUrl,
|
||||
logoUrl: optionalUrl,
|
||||
credentials: z.object({
|
||||
federatedIdentities: z.array(
|
||||
z.object({
|
||||
@@ -90,30 +89,40 @@
|
||||
const data = form.validate();
|
||||
if (!data) return;
|
||||
isLoading = true;
|
||||
|
||||
const success = await callback({
|
||||
...data,
|
||||
logo
|
||||
logo: $inputs.logoUrl?.value ? null : logo,
|
||||
logoUrl: $inputs.logoUrl?.value
|
||||
});
|
||||
// Reset form if client was successfully created
|
||||
|
||||
const hasLogo = logo != null || !!$inputs.logoUrl?.value;
|
||||
if (success && existingClient && hasLogo) {
|
||||
logoDataURL = cachedOidcClientLogo.getUrl(existingClient.id);
|
||||
}
|
||||
|
||||
if (success && !existingClient) form.reset();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
function onLogoChange(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0] || null;
|
||||
if (file) {
|
||||
logo = file;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
logoDataURL = event.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
function onLogoChange(input: File | string | null) {
|
||||
if (input == null) return;
|
||||
|
||||
if (typeof input === 'string') {
|
||||
logo = null;
|
||||
logoDataURL = input || null;
|
||||
$inputs.logoUrl!.value = input;
|
||||
} else {
|
||||
logo = input;
|
||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
||||
logoDataURL = URL.createObjectURL(input);
|
||||
}
|
||||
}
|
||||
|
||||
function resetLogo() {
|
||||
logo = null;
|
||||
logoDataURL = null;
|
||||
$inputs.logoUrl && ($inputs.logoUrl.value = '');
|
||||
}
|
||||
|
||||
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
|
||||
@@ -173,32 +182,13 @@
|
||||
bind:checked={$inputs.requiresReauthentication.value}
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-8">
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
<div class="mt-2 flex items-end gap-3">
|
||||
{#if logoDataURL}
|
||||
<ImageBox
|
||||
class="size-24"
|
||||
src={logoDataURL}
|
||||
alt={m.name_logo({ name: $inputs.name.value })}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileInput
|
||||
id="logo"
|
||||
variant="secondary"
|
||||
accept="image/png, image/jpeg, image/svg+xml, image/webp, image/avif, image/heic"
|
||||
onchange={onLogoChange}
|
||||
>
|
||||
<Button variant="secondary">
|
||||
{logoDataURL ? m.change_logo() : m.upload_logo()}
|
||||
</Button>
|
||||
</FileInput>
|
||||
{#if logoDataURL}
|
||||
<Button variant="outline" onclick={resetLogo}>{m.remove_logo()}</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-7">
|
||||
<OidcClientImageInput
|
||||
{logoDataURL}
|
||||
{resetLogo}
|
||||
clientName={$inputs.name.value}
|
||||
{onLogoChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if showAdvancedOptions}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<script lang="ts">
|
||||
import UrlFileInput from '$lib/components/form/url-file-input.svelte';
|
||||
import ImageBox from '$lib/components/image-box.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { LucideX } from '@lucide/svelte';
|
||||
|
||||
let {
|
||||
logoDataURL,
|
||||
clientName,
|
||||
resetLogo,
|
||||
onLogoChange
|
||||
}: {
|
||||
logoDataURL: string | null;
|
||||
clientName: string;
|
||||
resetLogo: () => void;
|
||||
onLogoChange: (file: File | string | null) => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Label for="logo">{m.logo()}</Label>
|
||||
<div class="flex items-end gap-4">
|
||||
{#if logoDataURL}
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="relative shrink-0">
|
||||
<ImageBox class="size-24" src={logoDataURL} alt={m.name_logo({ name: clientName })} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon"
|
||||
onclick={resetLogo}
|
||||
class="absolute -top-2 -right-2 size-6 rounded-full shadow-md"
|
||||
>
|
||||
<LucideX class="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<UrlFileInput label={m.upload_logo()} accept="image/*" onchange={onLogoChange} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +59,7 @@
|
||||
<Table.Cell class="w-8 font-medium">
|
||||
{#if item.hasLogo}
|
||||
<ImageBox
|
||||
class="min-h-8 min-w-8 object-contain"
|
||||
class="min-h-8 min-w-8"
|
||||
src={cachedOidcClientLogo.getUrl(item.id)}
|
||||
alt={m.name_logo({ name: item.name })}
|
||||
/>
|
||||
@@ -71,16 +71,21 @@
|
||||
? item.allowedUserGroupsCount
|
||||
: m.unrestricted()}</Table.Cell
|
||||
>
|
||||
<Table.Cell class="flex justify-end gap-1">
|
||||
<Button
|
||||
href="/settings/admin/oidc-clients/{item.id}"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.edit()}><LucidePencil class="size-3 " /></Button
|
||||
>
|
||||
<Button onclick={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
|
||||
><LucideTrash class="size-3 text-red-500" /></Button
|
||||
>
|
||||
<Table.Cell class="align-middle">
|
||||
<div class="flex justify-end gap-1">
|
||||
<Button
|
||||
href="/settings/admin/oidc-clients/{item.id}"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.edit()}><LucidePencil class="size-3 " /></Button
|
||||
>
|
||||
<Button
|
||||
onclick={() => deleteClient(item)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
aria-label={m.delete()}><LucideTrash class="size-3 text-red-500" /></Button
|
||||
>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
|
||||
<Dialog.Description>
|
||||
{#if user}
|
||||
{m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })}
|
||||
{m.preview_for_user({ name: user.displayName })}
|
||||
{:else}
|
||||
{m.preview_the_oidc_data_that_would_be_sent_for_this_user()}
|
||||
{/if}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
{selectionDisabled}
|
||||
>
|
||||
{#snippet rows({ item })}
|
||||
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell>
|
||||
<Table.Cell>{item.displayName}</Table.Cell>
|
||||
<Table.Cell>{item.email}</Table.Cell>
|
||||
{/snippet}
|
||||
</AdvancedTable>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { preventDefault } from '$lib/utils/event-util';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { emptyToUndefined, usernameSchema } from '$lib/utils/zod-util';
|
||||
import { get } from 'svelte/store';
|
||||
import { z } from 'zod/v4';
|
||||
|
||||
let {
|
||||
@@ -35,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(),
|
||||
disabled: z.boolean()
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="flex gap-3">
|
||||
<div class="aspect-square h-[56px]">
|
||||
<ImageBox
|
||||
class="grow rounded-lg object-contain"
|
||||
class="size-8"
|
||||
src={client.hasLogo
|
||||
? cachedOidcClientLogo.getUrl(client.id)
|
||||
: cachedApplicationLogo.getUrl(isLightMode)}
|
||||
@@ -124,6 +124,3 @@
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user