Compare commits
1 Commits
97c67670e5
...
SEND010
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ecd17d2dc |
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
20
frontend/pshared/lib/api/requests/username.dart
Normal file
20
frontend/pshared/lib/api/requests/username.dart
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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'],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "содержит строчную букву",
|
||||||
|
|||||||
1
frontend/pweb/lib/models/edit_state.dart
Normal file
1
frontend/pweb/lib/models/edit_state.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
enum EditState { view, edit, saving }
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
frontend/pweb/lib/providers/password_form.dart
Normal file
87
frontend/pweb/lib/providers/password_form.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user