1 Commits

Author SHA1 Message Date
Arseni
0ecd17d2dc Updated Settings Page 2025-12-18 15:15:33 +03:00
44 changed files with 792 additions and 597 deletions

View File

@@ -22,7 +22,7 @@ require (
require ( require (
github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c // indirect github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect

View File

@@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ=
github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c h1:1HaIKi7tUhYKk05NOy2tgqtDky4aVXjCeTaBU7ziJZE= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62 h1:Rge3uIIO891+nLqKTfMulCw+tWHtTl16Oudi0yUcAoE=
github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251222010151-8a13a32a690c/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251213223233-751f36331c62/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI=
github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0=
github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -9,7 +9,6 @@ import (
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
"github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/api/routers/gsresponse"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"go.uber.org/zap" "go.uber.org/zap"
@@ -96,31 +95,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address")) return gsresponse.Internal[chainv1.CreateManagedWalletResponse](c.deps.Logger, mservice.ChainGateway, merrors.Internal("key manager returned empty address"))
} }
metadata := shared.CloneMetadata(req.GetMetadata())
desc := req.GetDescribable()
name := strings.TrimSpace(desc.GetName())
if name == "" {
name = strings.TrimSpace(metadata["name"])
}
var description *string
if desc != nil && desc.Description != nil {
if trimmed := strings.TrimSpace(desc.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(metadata["description"]); trimmed != "" {
description = &trimmed
}
}
if name == "" {
name = walletRef
}
wallet := &model.ManagedWallet{ wallet := &model.ManagedWallet{
Describable: pkgmodel.Describable{
Name: name,
},
IdempotencyKey: idempotencyKey, IdempotencyKey: idempotencyKey,
WalletRef: walletRef, WalletRef: walletRef,
OrganizationRef: organizationRef, OrganizationRef: organizationRef,
@@ -131,10 +106,7 @@ func (c *createManagedWalletCommand) Execute(ctx context.Context, req *chainv1.C
DepositAddress: strings.ToLower(keyInfo.Address), DepositAddress: strings.ToLower(keyInfo.Address),
KeyReference: keyInfo.KeyID, KeyReference: keyInfo.KeyID,
Status: model.ManagedWalletStatusActive, Status: model.ManagedWalletStatusActive,
Metadata: metadata, Metadata: shared.CloneMetadata(req.GetMetadata()),
}
if description != nil {
wallet.Describable.Description = description
} }
created, err := c.deps.Storage.Wallets().Create(ctx, wallet) created, err := c.deps.Storage.Wallets().Create(ctx, wallet)

View File

@@ -1,11 +1,8 @@
package wallet package wallet
import ( import (
"strings"
"github.com/tech/sendico/gateway/chain/internal/service/gateway/shared" "github.com/tech/sendico/gateway/chain/internal/service/gateway/shared"
"github.com/tech/sendico/gateway/chain/storage/model" "github.com/tech/sendico/gateway/chain/storage/model"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -19,25 +16,6 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
TokenSymbol: wallet.TokenSymbol, TokenSymbol: wallet.TokenSymbol,
ContractAddress: wallet.ContractAddress, ContractAddress: wallet.ContractAddress,
} }
name := strings.TrimSpace(wallet.Name)
if name == "" {
name = strings.TrimSpace(wallet.Metadata["name"])
}
if name == "" {
name = wallet.WalletRef
}
description := ""
switch {
case wallet.Description != nil:
description = strings.TrimSpace(*wallet.Description)
default:
description = strings.TrimSpace(wallet.Metadata["description"])
}
desc := &describablev1.Describable{Name: name}
if description != "" {
desc.Description = &description
}
return &chainv1.ManagedWallet{ return &chainv1.ManagedWallet{
WalletRef: wallet.WalletRef, WalletRef: wallet.WalletRef,
OrganizationRef: wallet.OrganizationRef, OrganizationRef: wallet.OrganizationRef,
@@ -48,7 +26,6 @@ func toProtoManagedWallet(wallet *model.ManagedWallet) *chainv1.ManagedWallet {
Metadata: shared.CloneMetadata(wallet.Metadata), Metadata: shared.CloneMetadata(wallet.Metadata),
CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()), CreatedAt: timestamppb.New(wallet.CreatedAt.UTC()),
UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()), UpdatedAt: timestamppb.New(wallet.UpdatedAt.UTC()),
Describable: desc,
} }
} }

View File

@@ -5,7 +5,6 @@ import (
"time" "time"
"github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/db/storable"
pkgmodel "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice" "github.com/tech/sendico/pkg/mservice"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
) )
@@ -20,8 +19,7 @@ const (
// ManagedWallet represents a user-controlled on-chain wallet managed by the service. // ManagedWallet represents a user-controlled on-chain wallet managed by the service.
type ManagedWallet struct { type ManagedWallet struct {
storable.Base `bson:",inline" json:",inline"` storable.Base `bson:",inline" json:",inline"`
pkgmodel.Describable `bson:",inline" json:",inline"`
IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"` IdempotencyKey string `bson:"idempotencyKey" json:"idempotencyKey"`
WalletRef string `bson:"walletRef" json:"walletRef"` WalletRef string `bson:"walletRef" json:"walletRef"`
@@ -79,15 +77,6 @@ func (m *ManagedWallet) Normalize() {
m.WalletRef = strings.TrimSpace(m.WalletRef) m.WalletRef = strings.TrimSpace(m.WalletRef)
m.OrganizationRef = strings.TrimSpace(m.OrganizationRef) m.OrganizationRef = strings.TrimSpace(m.OrganizationRef)
m.OwnerRef = strings.TrimSpace(m.OwnerRef) m.OwnerRef = strings.TrimSpace(m.OwnerRef)
m.Name = strings.TrimSpace(m.Name)
if m.Description != nil {
desc := strings.TrimSpace(*m.Description)
if desc == "" {
m.Description = nil
} else {
m.Description = &desc
}
}
m.Network = strings.TrimSpace(strings.ToLower(m.Network)) m.Network = strings.TrimSpace(strings.ToLower(m.Network))
m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol)) m.TokenSymbol = strings.TrimSpace(strings.ToUpper(m.TokenSymbol))
m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress)) m.ContractAddress = strings.TrimSpace(strings.ToLower(m.ContractAddress))

View File

@@ -5,7 +5,9 @@ import (
"time" "time"
"github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/payments/orchestrator/storage/model"
"github.com/tech/sendico/pkg/merrors"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
@@ -411,3 +413,74 @@ func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.Es
} }
return nil return nil
} }
func protoFailureToModel(code orchestratorv1.PaymentFailureCode) model.PaymentFailureCode {
switch code {
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE:
return model.PaymentFailureCodeBalance
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER:
return model.PaymentFailureCodeLedger
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX:
return model.PaymentFailureCodeFX
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN:
return model.PaymentFailureCodeChain
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES:
return model.PaymentFailureCodeFees
case orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY:
return model.PaymentFailureCodePolicy
default:
return model.PaymentFailureCodeUnspecified
}
}
func applyProtoPaymentToModel(src *orchestratorv1.Payment, dst *model.Payment) error {
if src == nil || dst == nil {
return merrors.InvalidArgument("payment payload is required")
}
dst.PaymentRef = strings.TrimSpace(src.GetPaymentRef())
dst.IdempotencyKey = strings.TrimSpace(src.GetIdempotencyKey())
dst.Intent = intentFromProto(src.GetIntent())
dst.State = modelStateFromProto(src.GetState())
dst.FailureCode = protoFailureToModel(src.GetFailureCode())
dst.FailureReason = strings.TrimSpace(src.GetFailureReason())
dst.Metadata = cloneMetadata(src.GetMetadata())
dst.LastQuote = quoteSnapshotToModel(src.GetLastQuote())
dst.Execution = executionFromProto(src.GetExecution())
if src.GetCardPayout() != nil {
dst.CardPayout = &model.CardPayout{
PayoutRef: strings.TrimSpace(src.GetCardPayout().GetPayoutRef()),
ProviderPaymentID: strings.TrimSpace(src.GetCardPayout().GetProviderPaymentId()),
Status: strings.TrimSpace(src.GetCardPayout().GetStatus()),
FailureReason: strings.TrimSpace(src.GetCardPayout().GetFailureReason()),
CardCountry: strings.TrimSpace(src.GetCardPayout().GetCardCountry()),
MaskedPan: strings.TrimSpace(src.GetCardPayout().GetMaskedPan()),
ProviderCode: strings.TrimSpace(src.GetCardPayout().GetProviderCode()),
GatewayReference: strings.TrimSpace(src.GetCardPayout().GetGatewayReference()),
}
}
return nil
}
func executionFromProto(src *orchestratorv1.ExecutionRefs) *model.ExecutionRefs {
if src == nil {
return nil
}
return &model.ExecutionRefs{
DebitEntryRef: strings.TrimSpace(src.GetDebitEntryRef()),
CreditEntryRef: strings.TrimSpace(src.GetCreditEntryRef()),
FXEntryRef: strings.TrimSpace(src.GetFxEntryRef()),
ChainTransferRef: strings.TrimSpace(src.GetChainTransferRef()),
CardPayoutRef: strings.TrimSpace(src.GetCardPayoutRef()),
FeeTransferRef: strings.TrimSpace(src.GetFeeTransferRef()),
}
}
func ensurePageRequest(req *orchestratorv1.ListPaymentsRequest) *paginationv1.CursorPageRequest {
if req == nil {
return &paginationv1.CursorPageRequest{}
}
if req.GetPage() == nil {
return &paginationv1.CursorPageRequest{}
}
return req.GetPage()
}

View File

@@ -214,6 +214,20 @@ func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) {
return decimal.NewFromString(m.GetAmount()) return decimal.NewFromString(m.GetAmount())
} }
func decimalFromMoneyMatching(reference, candidate *moneyv1.Money) (*decimal.Decimal, error) {
if reference == nil || candidate == nil {
return nil, nil
}
if !strings.EqualFold(reference.GetCurrency(), candidate.GetCurrency()) {
return nil, nil
}
value, err := decimal.NewFromString(candidate.GetAmount())
if err != nil {
return nil, err
}
return &value, nil
}
func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money { func makeMoney(currency string, value decimal.Decimal) *moneyv1.Money {
return &moneyv1.Money{ return &moneyv1.Money{
Currency: currency, Currency: currency,

View File

@@ -57,7 +57,7 @@ func TestMinQuoteExpiry(t *testing.T) {
later := now.Add(10 * time.Minute) later := now.Add(10 * time.Minute)
earliest := now.Add(5 * time.Minute) earliest := now.Add(5 * time.Minute)
min, ok := minQuoteExpiry([]time.Time{later, {}, earliest}) min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest})
if !ok { if !ok {
t.Fatal("expected min expiry to be set") t.Fatal("expected min expiry to be set")
} }
@@ -65,7 +65,7 @@ func TestMinQuoteExpiry(t *testing.T) {
t.Fatalf("expected min expiry %v, got %v", earliest, min) t.Fatalf("expected min expiry %v, got %v", earliest, min)
} }
if _, ok := minQuoteExpiry([]time.Time{{}}); ok { if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok {
t.Fatal("expected min expiry to be unset") t.Fatal("expected min expiry to be unset")
} }
} }

View File

@@ -37,9 +37,7 @@ func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error)
{Field: "clientId", Sort: ri.Asc}, {Field: "clientId", Sort: ri.Asc},
{Field: "deviceId", Sort: ri.Asc}, {Field: "deviceId", Sort: ri.Asc},
}, },
Unique: true, Unique: true,
Name: "unique_active_session",
PartialFilter: repository.Filter(IsRevokedField, false),
}); err != nil { }); err != nil {
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err)) p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
return nil, err return nil, err

View File

@@ -10,29 +10,23 @@ import (
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb" "github.com/tech/sendico/pkg/db/internal/mongo/refreshtokensdb"
"github.com/tech/sendico/pkg/db/repository" "github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/db/repository/builder" "github.com/tech/sendico/pkg/db/repository/builder"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
factory "github.com/tech/sendico/pkg/mlogger/factory" factory "github.com/tech/sendico/pkg/mlogger/factory"
"github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb" "github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait" "github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options" "go.mongodb.org/mongo-driver/mongo/options"
) )
func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) { func setupTestDB(t *testing.T) (*refreshtokensdb.RefreshTokenDB, func()) {
db, _, cleanup := setupTestDBWithMongo(t)
return db, cleanup
}
func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo.Database, func()) {
// mark as helper for better test failure reporting // mark as helper for better test failure reporting
t.Helper() t.Helper()
@@ -68,7 +62,7 @@ func setupTestDBWithMongo(t *testing.T) (*refreshtokensdb.RefreshTokenDB, *mongo
_ = mongoContainer.Terminate(termCtx) _ = mongoContainer.Terminate(termCtx)
} }
return db, database, cleanup return db, cleanup
} }
func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken { func createTestRefreshToken(accountRef primitive.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
@@ -338,63 +332,6 @@ func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
_, err = db.GetByCRT(ctx, secondCRT) _, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err) require.NoError(t, err)
}) })
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
userID := primitive.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
// Global revoke (deviceID empty) — all tokens should be revoked
err = db.RevokeAll(ctx, userID, "")
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, *firstToken.GetID(), &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
// Creating a new token for the same account/client/device must succeed and produce an active token
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
err = db.Create(ctx, reissueToken)
require.NoError(t, err)
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "new_token_after_revocation",
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
// Old token must remain unusable
oldCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "revoked_token_123",
}
_, err = db.GetByCRT(ctx, oldCRT)
assert.Error(t, err)
// Both records exist: revoked + new active
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
} }
func TestRefreshTokenDB_ClientManagement(t *testing.T) { func TestRefreshTokenDB_ClientManagement(t *testing.T) {
@@ -700,29 +637,3 @@ func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
assert.Len(t, ids, 5) // Should find 5 non-revoked tokens assert.Len(t, ids, 5) // Should find 5 non-revoked tokens
}) })
} }
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
db, database, cleanup := setupTestDBWithMongo(t)
defer cleanup()
ctx := context.Background()
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == "unique_active_session" {
found = true
assert.Equal(t, true, idx["unique"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"isRevoked": false}, partial)
}
}
assert.True(t, found, "unique_active_session index not found")
}

View File

@@ -41,9 +41,6 @@ func (r *MongoRepository) CreateIndex(def *ri.Definition) error {
if def.Name != "" { if def.Name != "" {
opts.SetName(def.Name) opts.SetName(def.Name)
} }
if def.PartialFilter != nil {
opts.SetPartialFilterExpression(def.PartialFilter.BuildQuery())
}
_, err := r.collection.Indexes().CreateOne( _, err := r.collection.Indexes().CreateOne(
context.Background(), context.Background(),

View File

@@ -1,83 +0,0 @@
//go:build integration
// +build integration
package repositoryimp_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tech/sendico/pkg/db/repository"
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func TestCreateIndex_WithPartialFilter(t *testing.T) {
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err)
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err)
client, err := mongo.Connect(startCtx, options.Client().ApplyURI(mongoURI))
require.NoError(t, err)
defer client.Disconnect(context.Background())
database := client.Database("test_partial_index_" + t.Name())
defer database.Drop(context.Background())
repo := repository.CreateMongoRepository(database, "partial_index_items")
def := &ri.Definition{
Keys: []ri.Key{
{Field: "field", Sort: ri.Asc},
},
Unique: true,
Name: "partial_unique_field_true",
PartialFilter: repository.Filter("flag", treu),
}
require.NoError(t, repo.CreateIndex(def))
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cursor, err := database.Collection(repo.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == def.Name {
found = true
assert.Equal(t, true, idx["unique"])
assert.Equal(t, bson.M{"field": int32(1)}, idx["key"])
partial, ok := idx["partialFilterExpression"].(bson.M)
require.True(t, ok)
assert.Equal(t, bson.M{"flag": true}, partial)
}
}
assert.True(t, found, "partial unique index was not created")
termCtx, termCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer termCancel()
_ = mongoContainer.Terminate(termCtx)
}

View File

@@ -1,7 +1,5 @@
package repository package repository
import "github.com/tech/sendico/pkg/db/repository/builder"
type Sort int8 type Sort int8
const ( const (
@@ -16,9 +14,8 @@ type Key struct {
} }
type Definition struct { type Definition struct {
Keys []Key // mandatory, at least one element Keys []Key // mandatory, at least one element
Unique bool // unique constraint? Unique bool // unique constraint?
TTL *int32 // seconds; nil means “no TTL” TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes
} }

View File

@@ -1,11 +0,0 @@
syntax = "proto3";
package common.describable.v1;
option go_package = "github.com/tech/sendico/pkg/proto/common/describable/v1;describablev1";
// Describable captures a name/description pair reusable across resources.
message Describable {
string name = 1;
optional string description = 2;
}

View File

@@ -7,7 +7,6 @@ option go_package = "github.com/tech/sendico/pkg/proto/gateway/chain/v1;chainv1"
import "google/protobuf/timestamp.proto"; import "google/protobuf/timestamp.proto";
import "common/money/v1/money.proto"; import "common/money/v1/money.proto";
import "common/pagination/v1/cursor.proto"; import "common/pagination/v1/cursor.proto";
import "common/describable/v1/describable.proto";
// Supported blockchain networks for the managed wallets. // Supported blockchain networks for the managed wallets.
enum ChainNetwork { enum ChainNetwork {
@@ -58,7 +57,6 @@ message ManagedWallet {
map<string, string> metadata = 7; map<string, string> metadata = 7;
google.protobuf.Timestamp created_at = 8; google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9; google.protobuf.Timestamp updated_at = 9;
common.describable.v1.Describable describable = 10;
} }
message CreateManagedWalletRequest { message CreateManagedWalletRequest {
@@ -67,7 +65,6 @@ message CreateManagedWalletRequest {
string owner_ref = 3; string owner_ref = 3;
Asset asset = 4; Asset asset = 4;
map<string, string> metadata = 5; map<string, string> metadata = 5;
common.describable.v1.Describable describable = 6;
} }
message CreateManagedWalletResponse { message CreateManagedWalletResponse {

View File

@@ -2,7 +2,6 @@ package sresponse
import ( import (
"net/http" "net/http"
"strings"
"time" "time"
"github.com/tech/sendico/pkg/api/http/response" "github.com/tech/sendico/pkg/api/http/response"
@@ -27,8 +26,6 @@ type wallet struct {
DepositAddress string `json:"depositAddress"` DepositAddress string `json:"depositAddress"`
Status string `json:"status"` Status string `json:"status"`
Metadata map[string]string `json:"metadata,omitempty"` Metadata map[string]string `json:"metadata,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
CreatedAt string `json:"createdAt,omitempty"` CreatedAt string `json:"createdAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"`
} }
@@ -83,27 +80,6 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
token = asset.GetTokenSymbol() token = asset.GetTokenSymbol()
contract = asset.GetContractAddress() contract = asset.GetContractAddress()
} }
name := ""
if d := w.GetDescribable(); d != nil {
name = strings.TrimSpace(d.GetName())
}
if name == "" {
name = strings.TrimSpace(w.GetMetadata()["name"])
}
if name == "" {
name = w.GetWalletRef()
}
var description *string
if d := w.GetDescribable(); d != nil && d.Description != nil {
if trimmed := strings.TrimSpace(d.GetDescription()); trimmed != "" {
description = &trimmed
}
}
if description == nil {
if trimmed := strings.TrimSpace(w.GetMetadata()["description"]); trimmed != "" {
description = &trimmed
}
}
return wallet{ return wallet{
WalletRef: w.GetWalletRef(), WalletRef: w.GetWalletRef(),
OrganizationRef: w.GetOrganizationRef(), OrganizationRef: w.GetOrganizationRef(),
@@ -116,8 +92,6 @@ func toWallet(w *chainv1.ManagedWallet) wallet {
DepositAddress: w.GetDepositAddress(), DepositAddress: w.GetDepositAddress(),
Status: w.GetStatus().String(), Status: w.GetStatus().String(),
Metadata: w.GetMetadata(), Metadata: w.GetMetadata(),
Name: name,
Description: description,
CreatedAt: tsToString(w.GetCreatedAt()), CreatedAt: tsToString(w.GetCreatedAt()),
UpdatedAt: tsToString(w.GetUpdatedAt()), UpdatedAt: tsToString(w.GetUpdatedAt()),
} }

View File

@@ -53,7 +53,9 @@ func (pr *PublicRouter) logUserIn(ctx context.Context, _ *http.Request, req *sre
pr.logger.Warn("Failed to create login confirmation code", zap.Error(err)) pr.logger.Warn("Failed to create login confirmation code", zap.Error(err))
return response.Internal(pr.logger, pr.service, err) return response.Internal(pr.logger, pr.service, err)
} }
pr.logger.Info("Login confirmation code issued", zap.String("destination", pr.maskEmail(account.Login))) pr.logger.Info("Login confirmation code issued",
zap.String("destination", pr.maskEmail(account.Login)),
zap.String("account", account.Login))
return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds())) return sresponse.LoginPending(pr.logger, account, &pendingToken, pr.maskEmail(account.Login), int(time.Until(rec.ExpiresAt).Seconds()))
} }

View File

@@ -1,25 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/requests/payment/base.dart';
import 'package:pshared/data/dto/payment/intent/payment.dart';
part 'quotes.g.dart';
@JsonSerializable()
class QuotePaymentsRequest extends PaymentBaseRequest {
final List<PaymentIntentDTO> intents;
@JsonKey(defaultValue: false)
final bool previewOnly;
const QuotePaymentsRequest({
required super.idempotencyKey,
super.metadata,
required this.intents,
this.previewOnly = false,
});
factory QuotePaymentsRequest.fromJson(Map<String, dynamic> json) => _$QuotePaymentsRequestFromJson(json);
@override
Map<String, dynamic> toJson() => _$QuotePaymentsRequestToJson(this);
}

View File

@@ -0,0 +1,20 @@
import 'package:json_annotation/json_annotation.dart';
part 'username.g.dart';
@JsonSerializable()
class ResetUserNameRequest {
final String userName;
const ResetUserNameRequest({
required this.userName,
});
factory ResetUserNameRequest.fromJson(Map<String, dynamic> json) => _$ResetUserNameRequestFromJson(json);
Map<String, dynamic> toJson() => _$ResetUserNameRequestToJson(this);
static ResetUserNameRequest build({
required String userName,
}) => ResetUserNameRequest(userName: userName);
}

View File

@@ -1,20 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/api/responses/base.dart';
import 'package:pshared/api/responses/token.dart';
import 'package:pshared/data/dto/payment/quotes.dart';
part 'quotes.g.dart';
@JsonSerializable(explicitToJson: true)
class PaymentQuotesResponse extends BaseAuthorizedResponse {
final PaymentQuotesDTO quote;
const PaymentQuotesResponse({required super.accessToken, required this.quote});
factory PaymentQuotesResponse.fromJson(Map<String, dynamic> json) => _$PaymentQuotesResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$PaymentQuotesResponseToJson(this);
}

View File

@@ -1,24 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/money.dart';
part 'quote_aggregate.g.dart';
@JsonSerializable()
class PaymentQuoteAggregateDTO {
final List<MoneyDTO>? debitAmounts;
final List<MoneyDTO>? expectedSettlementAmounts;
final List<MoneyDTO>? expectedFeeTotals;
final List<MoneyDTO>? networkFeeTotals;
const PaymentQuoteAggregateDTO({
this.debitAmounts,
this.expectedSettlementAmounts,
this.expectedFeeTotals,
this.networkFeeTotals,
});
factory PaymentQuoteAggregateDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuoteAggregateDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuoteAggregateDTOToJson(this);
}

View File

@@ -1,23 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
import 'package:pshared/data/dto/payment/payment_quote.dart';
part 'quotes.g.dart';
@JsonSerializable()
class PaymentQuotesDTO {
final String quoteRef;
final PaymentQuoteAggregateDTO? aggregate;
final List<PaymentQuoteDTO>? quotes;
const PaymentQuotesDTO({
required this.quoteRef,
this.aggregate,
this.quotes,
});
factory PaymentQuotesDTO.fromJson(Map<String, dynamic> json) => _$PaymentQuotesDTOFromJson(json);
Map<String, dynamic> toJson() => _$PaymentQuotesDTOToJson(this);
}

View File

@@ -13,8 +13,6 @@ class WalletDTO {
final WalletAssetDTO asset; final WalletAssetDTO asset;
final String depositAddress; final String depositAddress;
final String status; final String status;
final String name;
final String? description;
final Map<String, String>? metadata; final Map<String, String>? metadata;
final String? createdAt; final String? createdAt;
final String? updatedAt; final String? updatedAt;
@@ -26,8 +24,6 @@ class WalletDTO {
required this.asset, required this.asset,
required this.depositAddress, required this.depositAddress,
required this.status, required this.status,
required this.name,
this.description,
this.metadata, this.metadata,
this.createdAt, this.createdAt,
this.updatedAt, this.updatedAt,

View File

@@ -1,22 +0,0 @@
import 'package:pshared/data/dto/payment/quote_aggregate.dart';
import 'package:pshared/data/mapper/payment/money.dart';
import 'package:pshared/models/payment/quote_aggregate.dart';
extension PaymentQuoteAggregateDTOMapper on PaymentQuoteAggregateDTO {
PaymentQuoteAggregate toDomain() => PaymentQuoteAggregate(
debitAmounts: debitAmounts?.map((amount) => amount.toDomain()).toList(),
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDomain()).toList(),
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDomain()).toList(),
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDomain()).toList(),
);
}
extension PaymentQuoteAggregateMapper on PaymentQuoteAggregate {
PaymentQuoteAggregateDTO toDTO() => PaymentQuoteAggregateDTO(
debitAmounts: debitAmounts?.map((amount) => amount.toDTO()).toList(),
expectedSettlementAmounts: expectedSettlementAmounts?.map((amount) => amount.toDTO()).toList(),
expectedFeeTotals: expectedFeeTotals?.map((amount) => amount.toDTO()).toList(),
networkFeeTotals: networkFeeTotals?.map((amount) => amount.toDTO()).toList(),
);
}

View File

@@ -1,21 +0,0 @@
import 'package:pshared/data/dto/payment/quotes.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/data/mapper/payment/quote_aggregate.dart';
import 'package:pshared/models/payment/quotes.dart';
extension PaymentQuotesDTOMapper on PaymentQuotesDTO {
PaymentQuotes toDomain() => PaymentQuotes(
quoteRef: quoteRef,
aggregate: aggregate?.toDomain(),
quotes: quotes?.map((quote) => quote.toDomain()).toList(),
);
}
extension PaymentQuotesMapper on PaymentQuotes {
PaymentQuotesDTO toDTO() => PaymentQuotesDTO(
quoteRef: quoteRef,
aggregate: aggregate?.toDTO(),
quotes: quotes?.map((quote) => quote.toDTO()).toList(),
);
}

View File

@@ -24,10 +24,8 @@ extension WalletDTOMapper on WalletDTO {
balance: balance?.toDomain(), balance: balance?.toDomain(),
availableMoney: balance?.available?.toDomain(), availableMoney: balance?.available?.toDomain(),
describable: newDescribable( describable: newDescribable(
name: name.isNotEmpty ? name : (metadata?['name'] ?? 'Crypto Wallet'), name: metadata?['name'] ?? 'Crypto Wallet',
description: (description != null && description!.isNotEmpty) description: metadata?['description'],
? description
: metadata?['description'],
), ),
); );
} }

View File

@@ -1,16 +0,0 @@
import 'package:pshared/models/payment/money.dart';
class PaymentQuoteAggregate {
final List<Money>? debitAmounts;
final List<Money>? expectedSettlementAmounts;
final List<Money>? expectedFeeTotals;
final List<Money>? networkFeeTotals;
const PaymentQuoteAggregate({
required this.debitAmounts,
required this.expectedSettlementAmounts,
required this.expectedFeeTotals,
required this.networkFeeTotals,
});
}

View File

@@ -1,15 +0,0 @@
import 'package:pshared/models/payment/quote.dart';
import 'package:pshared/models/payment/quote_aggregate.dart';
class PaymentQuotes {
final String quoteRef;
final PaymentQuoteAggregate? aggregate;
final List<PaymentQuote>? quotes;
const PaymentQuotes({
required this.quoteRef,
required this.aggregate,
required this.quotes,
});
}

View File

@@ -228,6 +228,19 @@ class AccountProvider extends ChangeNotifier {
} }
} }
Future<Account> resetUsername(String userName) async {
if (account == null) throw ErrorUnauthorized();
_setResource(_resource.copyWith(isLoading: true, error: null));
try {
final updated = await AccountService.resetUsername(account!, userName);
_setResource(Resource(data: updated, isLoading: false));
return updated;
} catch (e) {
_setResource(_resource.copyWith(isLoading: false, error: toException(e)));
rethrow;
}
}
Future<void> forgotPassword(String email) async { Future<void> forgotPassword(String email) async {
_setResource(_resource.copyWith(isLoading: true, error: null)); _setResource(_resource.copyWith(isLoading: true, error: null));
try { try {

View File

@@ -1,5 +1,4 @@
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'package:pshared/api/requests/signup.dart'; import 'package:pshared/api/requests/signup.dart';
@@ -10,6 +9,7 @@ import 'package:pshared/api/requests/password/forgot.dart';
import 'package:pshared/api/requests/password/reset.dart'; import 'package:pshared/api/requests/password/reset.dart';
import 'package:pshared/data/mapper/account/account.dart'; import 'package:pshared/data/mapper/account/account.dart';
import 'package:pshared/models/account/account.dart'; import 'package:pshared/models/account/account.dart';
import 'package:pshared/models/describable.dart';
import 'package:pshared/models/auth/login_outcome.dart'; import 'package:pshared/models/auth/login_outcome.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/files.dart'; import 'package:pshared/service/files.dart';
@@ -61,6 +61,14 @@ class AccountService {
await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson()); await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson());
} }
static Future<Account> resetUsername(Account account, String userName) async {
_logger.fine('Updating username for account: ${account.id}');
final updatedAccount = account.copyWith(
describable: account.describable.copyWith(name: userName),
);
return update(updatedAccount);
}
static Future<Account> changePassword(String oldPassword, String newPassword) async { static Future<Account> changePassword(String oldPassword, String newPassword) async {
_logger.fine('Changing password'); _logger.fine('Changing password');
return _getAccount(AuthorizationService.getPATCHResponse( return _getAccount(AuthorizationService.getPATCHResponse(

View File

@@ -1,13 +1,9 @@
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:pshared/api/requests/payment/quote.dart'; import 'package:pshared/api/requests/payment/quote.dart';
import 'package:pshared/api/requests/payment/quotes.dart';
import 'package:pshared/api/responses/payment/quotation.dart'; import 'package:pshared/api/responses/payment/quotation.dart';
import 'package:pshared/api/responses/payment/quotes.dart';
import 'package:pshared/data/mapper/payment/payment_quote.dart'; import 'package:pshared/data/mapper/payment/payment_quote.dart';
import 'package:pshared/data/mapper/payment/quotes.dart';
import 'package:pshared/models/payment/quote.dart'; import 'package:pshared/models/payment/quote.dart';
import 'package:pshared/models/payment/quotes.dart';
import 'package:pshared/service/authorization/service.dart'; import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart'; import 'package:pshared/service/services.dart';
@@ -25,14 +21,4 @@ class QuotationService {
); );
return PaymentQuoteResponse.fromJson(response).quote.toDomain(); return PaymentQuoteResponse.fromJson(response).quote.toDomain();
} }
static Future<PaymentQuotes> getMultiQuotation(String organizationRef, QuotePaymentsRequest request) async {
_logger.fine('Quoting payments for organization $organizationRef');
final response = await AuthorizationService.getPOSTResponse(
_objectType,
'/multiquote/$organizationRef',
request.toJson(),
);
return PaymentQuotesResponse.fromJson(response).quote.toDomain();
}
} }

View File

@@ -1,6 +1,8 @@
import 'package:pshared/models/wallet/wallet.dart' as domain; import 'package:pshared/models/wallet/wallet.dart' as domain;
import 'package:pshared/models/currency.dart'; import 'package:pshared/models/currency.dart';
import 'package:pshared/models/describable.dart';
import 'package:pweb/models/wallet.dart'; import 'package:pweb/models/wallet.dart';
@@ -24,7 +26,10 @@ extension WalletUiMapper on domain.WalletModel {
network: asset.chain, network: asset.chain,
tokenSymbol: asset.tokenSymbol, tokenSymbol: asset.tokenSymbol,
contractAddress: asset.contractAddress, contractAddress: asset.contractAddress,
describable: describable, describable: newDescribable(
name: metadata?['name'] ?? 'Crypto Wallet',
description: metadata?['description'],
),
); );
} }
} }

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Provide a valid email address", "usernameErrorInvalid": "Provide a valid email address",
"usernameUnknownTLD": "Domain .{domain} is not known, please, check it", "usernameUnknownTLD": "Domain .{domain} is not known, please, check it",
"password": "Password", "password": "Password",
"oldPassword": "Current password",
"newPassword": "New password",
"confirmPassword": "Confirm password", "confirmPassword": "Confirm password",
"changePassword": "Change password",
"savePassword": "Save changed password",
"changePasswordSuccess": "Password updated",
"changePasswordError": "Could not update password",
"passwordValidationRuleDigit": "has digit", "passwordValidationRuleDigit": "has digit",
"passwordValidationRuleUpperCase": "has uppercase letter", "passwordValidationRuleUpperCase": "has uppercase letter",
"passwordValidationRuleLowerCase": "has lowercase letter", "passwordValidationRuleLowerCase": "has lowercase letter",

View File

@@ -9,7 +9,13 @@
"usernameErrorInvalid": "Укажите действительный адрес электронной почты", "usernameErrorInvalid": "Укажите действительный адрес электронной почты",
"usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его", "usernameUnknownTLD": "Домен .{domain} неизвестен, пожалуйста, проверьте его",
"password": "Пароль", "password": "Пароль",
"oldPassword": "Текущий пароль",
"newPassword": "Новый пароль",
"confirmPassword": "Подтвердите пароль", "confirmPassword": "Подтвердите пароль",
"changePassword": "Изменить пароль",
"savePassword": "Сохранить пароль",
"changePasswordSuccess": "Пароль обновлен",
"changePasswordError": "Не удалось обновить пароль",
"passwordValidationRuleDigit": "содержит цифру", "passwordValidationRuleDigit": "содержит цифру",
"passwordValidationRuleUpperCase": "содержит заглавную букву", "passwordValidationRuleUpperCase": "содержит заглавную букву",
"passwordValidationRuleLowerCase": "содержит строчную букву", "passwordValidationRuleLowerCase": "содержит строчную букву",

View File

@@ -0,0 +1 @@
enum EditState { view, edit, saving }

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pshared/provider/account.dart';
class AccountName extends StatefulWidget { class AccountName extends StatefulWidget {
final String name; final String name;
@@ -26,8 +30,9 @@ class _AccountNameState extends State<AccountName> {
static const double _borderWidth = 2; static const double _borderWidth = 2;
late final TextEditingController _controller; late final TextEditingController _controller;
bool _isEditing = false; EditState _editState = EditState.view;
late String _originalName; late String _originalName;
String _errorText = '';
@override @override
void initState() { void initState() {
@@ -42,86 +47,131 @@ class _AccountNameState extends State<AccountName> {
super.dispose(); super.dispose();
} }
void _startEditing() => setState(() => _isEditing = true); void _startEditing() => setState(() => _editState = EditState.edit);
void _cancelEditing() { void _cancelEditing() {
setState(() { setState(() {
_controller.text = _originalName; _controller.text = _originalName;
_isEditing = false; _editState = EditState.view;
_errorText = '';
}); });
} }
void _saveEditing() { Future<void> _saveEditing(AccountProvider provider) async {
final newName = _controller.text.trim();
if (newName.isEmpty || newName == _originalName) {
_cancelEditing();
return;
}
setState(() { setState(() {
_originalName = _controller.text; _editState = EditState.saving;
_isEditing = false; _errorText = '';
}); });
try {
await provider.resetUsername(newName);
if (!mounted) return;
setState(() {
_originalName = newName;
_editState = EditState.view;
});
} catch (_) {
if (!mounted) return;
setState(() {
_errorText = widget.errorText;
_editState = EditState.edit;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(widget.errorText)),
);
return;
} finally {
if (!mounted) return;
if (_editState == EditState.saving) {
setState(() => _editState = EditState.edit);
}
}
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
return Column( return Consumer<AccountProvider>(
mainAxisSize: MainAxisSize.min, builder: (context, provider, _) {
children: [ final isEditing = _editState != EditState.view;
Row( final currentName = provider.account?.name ?? _originalName;
mainAxisAlignment: MainAxisAlignment.center, final isBusy = provider.isLoading || _editState == EditState.saving;
if (!isEditing && currentName != _originalName) {
_originalName = currentName;
_controller.text = currentName;
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
if (_isEditing) Row(
SizedBox( mainAxisAlignment: MainAxisAlignment.center,
width: _inputWidth, children: [
child: TextFormField( if (isEditing)
controller: _controller, SizedBox(
style: theme.textTheme.headlineMedium?.copyWith( width: _inputWidth,
fontWeight: FontWeight.bold, child: TextFormField(
), controller: _controller,
autofocus: true, style: theme.textTheme.headlineMedium?.copyWith(
decoration: InputDecoration( fontWeight: FontWeight.bold,
hintText: widget.hintText, ),
isDense: true, autofocus: true,
border: UnderlineInputBorder( enabled: !isBusy,
borderSide: BorderSide( decoration: InputDecoration(
color: theme.colorScheme.primary, hintText: widget.hintText,
width: _borderWidth, isDense: true,
border: UnderlineInputBorder(
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: _borderWidth,
),
),
), ),
), ),
)
else
Text(
_originalName,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
), const SizedBox(width: _spacing),
) if (isEditing) ...[
else IconButton(
icon: Icon(Icons.check, color: theme.colorScheme.primary),
onPressed: isBusy ? null : () => _saveEditing(provider),
),
IconButton(
icon: Icon(Icons.close, color: theme.colorScheme.error),
onPressed: isBusy ? null : _cancelEditing,
),
] else
IconButton(
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
onPressed: isBusy ? null : _startEditing,
),
],
),
const SizedBox(height: _errorSpacing),
if (_errorText.isNotEmpty)
Text( Text(
_originalName, _errorText,
style: theme.textTheme.headlineMedium?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold, color: theme.colorScheme.error,
), ),
), ),
const SizedBox(width: _spacing),
if (_isEditing) ...[
IconButton(
icon: Icon(Icons.check, color: theme.colorScheme.primary),
onPressed: _saveEditing,
),
IconButton(
icon: Icon(Icons.close, color: theme.colorScheme.error),
onPressed: _cancelEditing,
),
] else
IconButton(
icon: Icon(Icons.edit, color: theme.colorScheme.primary),
onPressed: _startEditing,
),
], ],
), );
const SizedBox(height: _errorSpacing), },
if (widget.errorText.isEmpty)
Text(
widget.errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
); );
} }
} }

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/providers/password_form.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class PasswordForm extends StatelessWidget {
const PasswordForm({
super.key,
required this.formProvider,
required this.accountProvider,
required this.isBusy,
required this.oldPasswordLabel,
required this.newPasswordLabel,
required this.confirmPasswordLabel,
required this.savePassword,
required this.successText,
required this.errorText,
required this.loc,
});
static const double _fieldWidth = 320;
static const double _gapMedium = 12;
static const double _gapSmall = 8;
final PasswordFormProvider formProvider;
final AccountProvider accountProvider;
final bool isBusy;
final String oldPasswordLabel;
final String newPasswordLabel;
final String confirmPasswordLabel;
final String savePassword;
final String successText;
final String errorText;
final AppLocalizations loc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isFormBusy = isBusy || formProvider.isSaving;
return Column(
children: [
const SizedBox(height: _gapMedium),
Form(
key: formProvider.formKey,
child: Column(
children: [
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: formProvider.oldPasswordController,
obscureText: true,
enabled: !isFormBusy,
decoration: InputDecoration(
labelText: oldPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.isEmpty) ? loc.errorPasswordMissing : null,
),
),
const SizedBox(height: _gapSmall),
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: formProvider.newPasswordController,
obscureText: true,
enabled: !isFormBusy,
decoration: InputDecoration(
labelText: newPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) =>
(value == null || value.isEmpty) ? loc.errorPasswordMissing : null,
),
),
const SizedBox(height: _gapSmall),
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: formProvider.confirmPasswordController,
obscureText: true,
enabled: !isFormBusy,
decoration: InputDecoration(
labelText: confirmPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return loc.errorPasswordMissing;
if (value != formProvider.newPasswordController.text) {
return loc.passwordsDoNotMatch;
}
return null;
},
),
),
const SizedBox(height: _gapMedium),
ElevatedButton.icon(
onPressed: isFormBusy
? null
: () => formProvider.submit(
context: context,
accountProvider: accountProvider,
successText: successText,
errorText: errorText,
),
icon: const Icon(Icons.save_outlined),
label: Text(savePassword),
),
if (formProvider.errorText.isNotEmpty) ...[
const SizedBox(height: _gapSmall),
Text(
formProvider.errorText,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
),
),
],
],
),
),
],
);
}
}

View File

@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/utils/snackbar.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pweb/utils/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class AccountPassword extends StatefulWidget {
final String title;
final String successText;
final String errorText;
final String oldPasswordLabel;
final String newPasswordLabel;
final String confirmPasswordLabel;
final String savePassword;
const AccountPassword({
super.key,
required this.title,
required this.successText,
required this.errorText,
required this.oldPasswordLabel,
required this.newPasswordLabel,
required this.confirmPasswordLabel,
required this.savePassword,
});
@override
State<AccountPassword> createState() => _AccountPasswordState();
}
class _AccountPasswordState extends State<AccountPassword> {
static const double _fieldWidth = 320;
static const double _gapMedium = 12;
static const double _gapSmall = 8;
final _formKey = GlobalKey<FormState>();
final _oldPasswordController = TextEditingController();
final _newPasswordController = TextEditingController();
final _confirmPasswordController = TextEditingController();
EditState _state = EditState.view;
String _errorText = '';
bool get _isSaving => _state == EditState.saving;
bool get _isExpanded => _state != EditState.view;
@override
void dispose() {
_oldPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
super.dispose();
}
Future<void> _changePassword(AccountProvider provider) async {
if (!_formKey.currentState!.validate()) return;
setState(() {
_state = EditState.saving;
_errorText = '';
});
try {
await provider.changePassword(_oldPasswordController.text, _newPasswordController.text);
if (!mounted) return;
_oldPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
notifyUser(context, widget.successText);
} catch (e) {
if (!mounted) return;
setState(() => _errorText = widget.errorText);
await postNotifyUserOfErrorX(
context: context,
errorSituation: widget.errorText,
exception: e,
);
} finally {
if (mounted) {
setState(() => _state = EditState.edit);
}
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
return Consumer<AccountProvider>(
builder: (context, provider, _) {
final isBusy = provider.isLoading || _isSaving;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
TextButton.icon(
onPressed: isBusy
? null
: () => setState(() {
_state = _isExpanded ? EditState.view : EditState.edit;
_errorText = '';
}),
icon: Icon(Icons.lock_outline, color: theme.colorScheme.primary),
label: Text(widget.title, style: theme.textTheme.bodyMedium),
),
if (_isExpanded) ...[
const SizedBox(height: _gapMedium),
Form(
key: _formKey,
child: Column(
children: [
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: _oldPasswordController,
obscureText: true,
enabled: !isBusy,
decoration: InputDecoration(
labelText: widget.oldPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) => (value == null || value.isEmpty) ? loc.errorPasswordMissing : null,
),
),
const SizedBox(height: _gapSmall),
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: _newPasswordController,
obscureText: true,
enabled: !isBusy,
decoration: InputDecoration(
labelText: widget.newPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) => (value == null || value.isEmpty) ? loc.errorPasswordMissing : null,
),
),
const SizedBox(height: _gapSmall),
SizedBox(
width: _fieldWidth,
child: TextFormField(
controller: _confirmPasswordController,
obscureText: true,
enabled: !isBusy,
decoration: InputDecoration(
labelText: widget.confirmPasswordLabel,
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return loc.errorPasswordMissing;
if (value != _newPasswordController.text) return loc.passwordsDoNotMatch;
return null;
},
),
),
const SizedBox(height: _gapMedium),
ElevatedButton.icon(
onPressed: isBusy ? null : () => _changePassword(provider),
icon: const Icon(Icons.save_outlined),
label: Text(widget.savePassword),
),
if (_errorText.isNotEmpty) ...[
const SizedBox(height: _gapSmall),
Text(
_errorText,
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
),
],
],
),
),
],
],
);
},
);
}
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class PasswordToggleButton extends StatelessWidget {
const PasswordToggleButton({
super.key,
required this.title,
required this.isExpanded,
required this.isBusy,
required this.onToggle,
});
final String title;
final bool isExpanded;
final bool isBusy;
final VoidCallback onToggle;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final iconColor = theme.colorScheme.primary;
return TextButton.icon(
onPressed: isBusy
? null
: () {
onToggle();
},
icon: Icon(
isExpanded ? Icons.lock_open : Icons.lock_outline,
color: iconColor,
),
label: Text(title, style: theme.textTheme.bodyMedium),
);
}
}

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/pages/settings/profile/account/avatar.dart'; import 'package:pweb/pages/settings/profile/account/avatar.dart';
import 'package:pweb/pages/settings/profile/account/locale.dart'; import 'package:pweb/pages/settings/profile/account/locale.dart';
import 'package:pweb/pages/settings/profile/account/name.dart'; import 'package:pweb/pages/settings/profile/account/name.dart';
import 'package:pweb/pages/settings/profile/account/password/password.dart';
import 'package:pweb/generated/i18n/app_localizations.dart'; import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -18,34 +23,48 @@ class ProfileSettingsPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!; final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context); final theme = Theme.of(context);
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
return Material( return Align(
elevation: 4, alignment: Alignment.topCenter,
borderRadius: BorderRadius.circular(_cardRadius), child: Material(
clipBehavior: Clip.antiAlias, elevation: 4,
color: theme.colorScheme.onSecondary, borderRadius: BorderRadius.circular(_cardRadius),
child: Padding( color: theme.colorScheme.onSecondary,
padding: _cardPadding, child: Padding(
child: Column( padding: _cardPadding,
mainAxisSize: MainAxisSize.min, child: Column(
spacing: _itemSpacing, mainAxisSize: MainAxisSize.min,
children: [ spacing: _itemSpacing,
AvatarTile( children: [
avatarUrl: 'https://avatars.githubusercontent.com/u/65651201', AvatarTile(
title: loc.avatar, avatarUrl: 'https://avatars.githubusercontent.com/u/65651201',
description: loc.avatarHint, title: loc.avatar,
errorText: loc.avatarUpdateError, description: loc.avatarHint,
), errorText: loc.avatarUpdateError,
AccountName( ),
name: loc.userNamePlaceholder, AccountName(
title: loc.accountName, name: accountName ?? loc.userNamePlaceholder,
hintText: loc.accountNameHint, title: loc.accountName,
errorText: loc.accountNameUpdateError, hintText: loc.accountNameHint,
), errorText: loc.accountNameUpdateError,
LocalePicker( ),
title: loc.language, AccountPassword(
), title: loc.changePassword,
], successText: loc.changePasswordSuccess,
errorText: loc.changePasswordError,
oldPasswordLabel: loc.oldPassword,
newPasswordLabel: loc.newPassword,
confirmPasswordLabel: loc.confirmPassword,
savePassword: loc.savePassword,
),
LocalePicker(
title: loc.language,
),
],
),
), ),
), ),
); );

View File

@@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_settings_ui/flutter_settings_ui.dart'; import 'package:flutter_settings_ui/flutter_settings_ui.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pweb/utils/error/snackbar.dart'; import 'package:pweb/utils/error/snackbar.dart';
enum _EditState { view, edit, saving } import 'package:pweb/generated/i18n/app_localizations.dart';
/// Базовый класс, управляющий состояниями (view/edit/saving),
/// показом snackbar ошибок и успешного сохранения.
abstract class BaseEditTile<T> extends AbstractSettingsTile { abstract class BaseEditTile<T> extends AbstractSettingsTile {
const BaseEditTile({ const BaseEditTile({
super.key, super.key,
@@ -24,11 +24,8 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
final Future<void> Function(T) valueSetter; final Future<void> Function(T) valueSetter;
final String errorSituation; final String errorSituation;
/// Рисует в режиме просмотра (read-only).
Widget buildView(BuildContext context, T? value); Widget buildView(BuildContext context, T? value);
/// Рисует UI редактора.
/// Если [useDialogEditor]==true, его обернут в диалог.
Widget buildEditor( Widget buildEditor(
BuildContext context, BuildContext context,
T? initial, T? initial,
@@ -37,7 +34,6 @@ abstract class BaseEditTile<T> extends AbstractSettingsTile {
bool isSaving, bool isSaving,
); );
/// true → показывать редактор в диалоге, false → inline под заголовком.
bool get useDialogEditor => false; bool get useDialogEditor => false;
@override @override
@@ -52,16 +48,16 @@ class _BaseEditTileBody<T> extends StatefulWidget {
} }
class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> { class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
_EditState _state = _EditState.view; EditState _state = EditState.view;
bool get _isSaving => _state == _EditState.saving; bool get _isSaving => _state == EditState.saving;
Future<void> _performSave(T newValue) async { Future<void> _performSave(T newValue) async {
final current = widget.delegate.valueGetter(); final current = widget.delegate.valueGetter();
if (newValue == current) { if (newValue == current) {
setState(() => _state = _EditState.view); setState(() => _state = EditState.view);
return; return;
} }
setState(() => _state = _EditState.saving); setState(() => _state = EditState.saving);
final sms = ScaffoldMessenger.of(context); final sms = ScaffoldMessenger.of(context);
final locs = AppLocalizations.of(context)!; final locs = AppLocalizations.of(context)!;
try { try {
@@ -78,7 +74,7 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
exception: e, exception: e,
); );
} finally { } finally {
if (mounted) setState(() => _state = _EditState.view); if (mounted) setState(() => _state = EditState.view);
} }
} }
@@ -112,7 +108,6 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
final delegate = widget.delegate; final delegate = widget.delegate;
final current = delegate.valueGetter(); final current = delegate.valueGetter();
// Диалоговый режим
if (delegate.useDialogEditor) { if (delegate.useDialogEditor) {
return SettingsTile.navigation( return SettingsTile.navigation(
leading: Icon(delegate.icon), leading: Icon(delegate.icon),
@@ -122,21 +117,20 @@ class _BaseEditTileBodyState<T> extends State<_BaseEditTileBody<T>> {
); );
} }
// Inline-режим (под заголовком будет редактор прямо в tile)
return SettingsTile.navigation( return SettingsTile.navigation(
leading: Icon(delegate.icon), leading: Icon(delegate.icon),
title: Text(delegate.title), title: Text(delegate.title),
value: _state == _EditState.view value: _state == EditState.view
? delegate.buildView(context, current) ? delegate.buildView(context, current)
: delegate.buildEditor( : delegate.buildEditor(
context, context,
current, current,
_performSave, _performSave,
() => setState(() => _state = _EditState.view), () => setState(() => _state = EditState.view),
_isSaving, _isSaving,
), ),
onPressed: (_) { onPressed: (_) {
if (_state == _EditState.view) setState(() => _state = _EditState.edit); if (_state == EditState.view) setState(() => _state = EditState.edit);
}, },
); );
} }

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/utils/snackbar.dart';
import 'package:pweb/models/edit_state.dart';
import 'package:pweb/utils/error/snackbar.dart';
class PasswordFormProvider extends ChangeNotifier {
final formKey = GlobalKey<FormState>();
final oldPasswordController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
EditState _state = EditState.view;
String _errorText = '';
bool _disposed = false;
bool get isExpanded => _state != EditState.view;
bool get isSaving => _state == EditState.saving;
String get errorText => _errorText;
EditState get state => _state;
void toggleExpanded() {
if (_state == EditState.saving) return;
_setState(_state == EditState.view ? EditState.edit : EditState.view);
_setError('');
}
Future<void> submit({
required BuildContext context,
required AccountProvider accountProvider,
required String successText,
required String errorText,
}) async {
final currentForm = formKey.currentState;
if (currentForm == null || !currentForm.validate()) return;
_setState(EditState.saving);
_setError('');
try {
await accountProvider.changePassword(
oldPasswordController.text,
newPasswordController.text,
);
oldPasswordController.clear();
newPasswordController.clear();
confirmPasswordController.clear();
if (!context.mounted) return;
notifyUser(context, successText);
} catch (e) {
_setError(errorText);
if (!context.mounted) return;
await postNotifyUserOfErrorX(
context: context,
errorSituation: errorText,
exception: e,
);
} finally {
_setState(EditState.edit);
}
}
void _setState(EditState value) {
if (_state == value || _disposed) return;
_state = value;
notifyListeners();
}
void _setError(String value) {
if (_disposed) return;
_errorText = value;
notifyListeners();
}
@override
void dispose() {
_disposed = true;
oldPasswordController.dispose();
newPasswordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
}

View File

@@ -1,5 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/widgets/sidebar/destinations.dart'; import 'package:pweb/widgets/sidebar/destinations.dart';
import 'package:pweb/widgets/sidebar/side_menu.dart'; import 'package:pweb/widgets/sidebar/side_menu.dart';
import 'package:pweb/widgets/sidebar/user.dart'; import 'package:pweb/widgets/sidebar/user.dart';
@@ -18,7 +22,7 @@ class PayoutSidebar extends StatelessWidget {
final PayoutDestination selected; final PayoutDestination selected;
final ValueChanged<PayoutDestination> onSelected; final ValueChanged<PayoutDestination> onSelected;
final VoidCallback? onLogout; final Future<void> Function()? onLogout;
final String? userName; final String? userName;
final String? avatarUrl; final String? avatarUrl;
@@ -27,6 +31,15 @@ class PayoutSidebar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accountName = context.select<AccountProvider, String?>(
(provider) => provider.account?.describable.name,
);
final accountAvatar = context.select<AccountProvider, String?>(
(provider) => provider.account?.avatarUrl,
);
final resolvedUserName = userName ?? accountName;
final resolvedAvatarUrl = avatarUrl ?? accountAvatar;
final menuItems = items ?? final menuItems = items ??
<PayoutDestination>[ <PayoutDestination>[
PayoutDestination.dashboard, PayoutDestination.dashboard,
@@ -42,16 +55,16 @@ class PayoutSidebar extends StatelessWidget {
children: [ children: [
UserProfileCard( UserProfileCard(
theme: theme, theme: theme,
avatarUrl: avatarUrl, avatarUrl: resolvedAvatarUrl,
userName: userName, userName: resolvedUserName,
selected: selected, selected: selected,
onSelected: onSelected onSelected: onSelected
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
SideMenuColumn( SideMenuColumn(
theme: theme, theme: theme,
avatarUrl: avatarUrl, avatarUrl: resolvedAvatarUrl,
userName: userName, userName: resolvedUserName,
items: menuItems, items: menuItems,
selected: selected, selected: selected,
onSelected: onSelected, onSelected: onSelected,

View File

@@ -65,7 +65,7 @@ dependencies:
flutter_settings_ui: ^3.0.1 flutter_settings_ui: ^3.0.1
pin_code_fields: ^8.0.1 pin_code_fields: ^8.0.1
fl_chart: ^1.0.0 fl_chart: ^1.0.0
syncfusion_flutter_charts: ^32.1.19 syncfusion_flutter_charts: ^31.2.10
flutter_multi_formatter: ^2.13.7 flutter_multi_formatter: ^2.13.7
dotted_border: ^3.1.0 dotted_border: ^3.1.0
qr_flutter: ^4.1.0 qr_flutter: ^4.1.0