3 Commits

Author SHA1 Message Date
Arseni
5d330c8ccc Email confirmation page after SignUp 2026-01-13 15:31:13 +03:00
dedde76dd7 Merge pull request 'Added ownership reference + wallet creation methods' (#243) from owner-242 into main
All checks were successful
ci/woodpecker/push/billing_fees Pipeline was successful
ci/woodpecker/push/db Pipeline was successful
ci/woodpecker/push/bff Pipeline was successful
ci/woodpecker/push/discovery Pipeline was successful
ci/woodpecker/push/fx_ingestor Pipeline was successful
ci/woodpecker/push/frontend Pipeline was successful
ci/woodpecker/push/gateway_mntx Pipeline was successful
ci/woodpecker/push/gateway_tgsettle Pipeline was successful
ci/woodpecker/push/nats Pipeline was successful
ci/woodpecker/push/fx_oracle Pipeline was successful
ci/woodpecker/push/gateway_chain Pipeline was successful
ci/woodpecker/push/ledger Pipeline was successful
ci/woodpecker/push/notification Pipeline was successful
ci/woodpecker/push/payments_orchestrator Pipeline was successful
Reviewed-on: #243
2026-01-07 12:14:59 +00:00
Stephan D
9e747e7251 Added ownership reference + wallet creation methods 2026-01-07 13:12:31 +01:00
31 changed files with 743 additions and 94 deletions

View File

@@ -0,0 +1,11 @@
package chainassets
import (
"context"
"github.com/tech/sendico/pkg/model"
)
type DB interface {
Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error)
}

View File

@@ -3,6 +3,7 @@ package db
import (
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/confirmation"
mongoimpl "github.com/tech/sendico/pkg/db/internal/mongo"
"github.com/tech/sendico/pkg/db/invitation"
@@ -22,6 +23,8 @@ type Factory interface {
NewRefreshTokensDB() (refreshtokens.DB, error)
NewConfirmationsDB() (confirmation.DB, error)
NewChainAsstesDB() (chainassets.DB, error)
NewAccountDB() (account.DB, error)
NewOrganizationDB() (organization.DB, error)
NewInvitationsDB() (invitation.DB, error)

View File

@@ -0,0 +1,73 @@
package chainassetsdb
import (
ri "github.com/tech/sendico/pkg/db/repository/index"
"github.com/tech/sendico/pkg/db/template"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"go.mongodb.org/mongo-driver/mongo"
"go.uber.org/zap"
)
type ChainAssetsDB struct {
template.DBImp[*model.ChainAssetDescription]
}
func Create(logger mlogger.Logger, db *mongo.Database) (*ChainAssetsDB, error) {
p := &ChainAssetsDB{
DBImp: *template.Create[*model.ChainAssetDescription](logger, mservice.ChainAssets, db),
}
// 1) Canonical lookup: enforce single (chain, tokenSymbol)
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_symbol",
Unique: true,
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
{Field: "asset.tokenSymbol", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index (chain, symbol) unique", zap.Error(err))
return nil, err
}
// 2) Prevent duplicate contracts inside the same chain, but only when contract exists
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_contract_unique",
Unique: true,
Sparse: true,
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
{Field: "asset.contractAddress", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index (chain, contract) unique", zap.Error(err))
return nil, err
}
// 3) Fast contract lookup, skip docs without contractAddress (native assets)
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_contract_lookup",
Sparse: true,
Keys: []ri.Key{
{Field: "asset.contractAddress", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index contract lookup", zap.Error(err))
return nil, err
}
// 4) List assets per chain
if err := p.Repository.CreateIndex(&ri.Definition{
Name: "idx_chain_list",
Keys: []ri.Key{
{Field: "asset.chain", Sort: ri.Asc},
},
}); err != nil {
p.Logger.Error("failed index chain list", zap.Error(err))
return nil, err
}
return p, nil
}

View File

@@ -0,0 +1,18 @@
package chainassetsdb
import (
"context"
"github.com/tech/sendico/pkg/db/repository"
"github.com/tech/sendico/pkg/model"
)
func (db *ChainAssetsDB) Resolve(ctx context.Context, chainAsset model.ChainAssetKey) (*model.ChainAssetDescription, error) {
var assetDescription model.ChainAssetDescription
assetField := repository.Field("asset")
q := repository.Query().And(
repository.Query().Filter(assetField.Dot("chain"), chainAsset.Chain),
repository.Query().Filter(assetField.Dot("tokenSymbol"), chainAsset.TokenSymbol),
)
return &assetDescription, db.DBImp.FindOne(ctx, q, &assetDescription)
}

View File

@@ -10,8 +10,10 @@ import (
"github.com/mitchellh/mapstructure"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/account"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/db/confirmation"
"github.com/tech/sendico/pkg/db/internal/mongo/accountdb"
"github.com/tech/sendico/pkg/db/internal/mongo/chainassetsdb"
"github.com/tech/sendico/pkg/db/internal/mongo/confirmationdb"
"github.com/tech/sendico/pkg/db/internal/mongo/invitationdb"
"github.com/tech/sendico/pkg/db/internal/mongo/organizationdb"
@@ -312,6 +314,10 @@ func collectReplicaHosts(configuredHosts []string, replicaSet, defaultPort, host
return hosts
}
func (db *DB) NewChainAsstesDB() (chainassets.DB, error) {
return chainassetsdb.Create(db.logger, db.db())
}
func (db *DB) Permissions() auth.Provider {
return db
}

View File

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

View File

@@ -18,6 +18,7 @@ type Key struct {
type Definition struct {
Keys []Key // mandatory, at least one element
Unique bool // unique constraint?
Sparse bool // sparse?
TTL *int32 // seconds; nil means “no TTL”
Name string // optional explicit name
PartialFilter builder.Query // optional: partialFilterExpression for conditional indexes

View File

@@ -0,0 +1,26 @@
package model
import (
"github.com/tech/sendico/pkg/db/storable"
"github.com/tech/sendico/pkg/mservice"
)
type ChainAssetKey struct {
Chain ChainNetwork `bson:"chain" json:"chain" yaml:"chain" mapstructure:"chain"`
TokenSymbol string `bson:"tokenSymbol" json:"tokenSymbol" yaml:"tokenSymbol" mapstructure:"tokenSymbol"`
}
type ChainAsset struct {
ChainAssetKey `bson:",inline" json:",inline"`
ContractAddress *string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"`
}
type ChainAssetDescription struct {
storable.Storable `bson:",inline" json:",inline"`
Describable `bson:",inline" json:",inline"`
Asset ChainAsset `bson:"asset" json:"asset"`
}
func Collection(*ChainAssetDescription) mservice.Type {
return mservice.ChainAssets
}

11
api/pkg/model/chains.go Normal file
View File

@@ -0,0 +1,11 @@
package model
type ChainNetwork string
const (
ChainNetworkARB ChainNetwork = "arbitrum_one"
ChainNetworkEthMain ChainNetwork = "ethereum_mainnet"
ChainNetworkTronMain ChainNetwork = "tron_mainnet"
ChainNetworkTronNile ChainNetwork = "tron_nile"
ChainNetworkUnspecified ChainNetwork = "unspecified"
)

View File

@@ -5,50 +5,51 @@ import "github.com/tech/sendico/pkg/merrors"
type Type = string
const (
Accounts Type = "accounts" // Represents user accounts in the system
Confirmations Type = "confirmations" // Represents confirmation code flows
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
Discovery Type = "discovery" // Represents service discovery registry
Site Type = "site" // Represents public site endpoints
Changes Type = "changes" // Tracks changes made to resources
Clients Type = "clients" // Represents client information
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice
PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
FeePlans Type = "fee_plans" // Represents fee plans microservice
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
Invitations Type = "invitations" // Represents invitations sent to users
Invoices Type = "invoices" // Represents invoices
Logo Type = "logo" // Represents logos for organizations or projects
Ledger Type = "ledger" // Represents ledger microservice
LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice
LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice
LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice
LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice
LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice
LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice
PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice
ChainWallets Type = "chain_wallets" // Represents managed chain wallets
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers
ChainDeposits Type = "chain_deposits" // Represents chain deposits
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
Accounts Type = "accounts" // Represents user accounts in the system
Confirmations Type = "confirmations" // Represents confirmation code flows
Amplitude Type = "amplitude" // Represents analytics integration with Amplitude
Discovery Type = "discovery" // Represents service discovery registry
Site Type = "site" // Represents public site endpoints
Changes Type = "changes" // Tracks changes made to resources
Clients Type = "clients" // Represents client information
ChainGateway Type = "chain_gateway" // Represents chain gateway microservice
MntxGateway Type = "mntx_gateway" // Represents Monetix gateway microservice
PaymentGateway Type = "payment_gateway" // Represents payment gateway microservice
FXOracle Type = "fx_oracle" // Represents FX oracle microservice
FeePlans Type = "fee_plans" // Represents fee plans microservice
FilterProjects Type = "filter_projects" // Represents comments on tasks or other resources
Invitations Type = "invitations" // Represents invitations sent to users
Invoices Type = "invoices" // Represents invoices
Logo Type = "logo" // Represents logos for organizations or projects
Ledger Type = "ledger" // Represents ledger microservice
LedgerAccounts Type = "ledger_accounts" // Represents ledger accounts microservice
LedgerBalances Type = "ledger_balances" // Represents ledger account balances microservice
LedgerEntries Type = "ledger_journal_entries" // Represents ledger journal entries microservice
LedgerOutbox Type = "ledger_outbox" // Represents ledger outbox microservice
LedgerParties Type = "ledger_parties" // Represents ledger account owner parties microservice
LedgerPlines Type = "ledger_posting_lines" // Represents ledger journal posting lines microservice
PaymentOrchestrator Type = "payment_orchestrator" // Represents payment orchestration microservice
ChainAssets Type = "chain_assets" // Represents managed chain assets
ChainWallets Type = "chain_wallets" // Represents managed chain wallets
ChainWalletBalances Type = "chain_wallet_balances" // Represents managed chain wallet balances
ChainTransfers Type = "chain_transfers" // Represents chain transfers
ChainDeposits Type = "chain_deposits" // Represents chain deposits
Notifications Type = "notifications" // Represents notifications sent to users
Organizations Type = "organizations" // Represents organizations in the system
Payments Type = "payments" // Represents payments service
PaymentRoutes Type = "payment_routes" // Represents payment routing definitions
PaymentPlanTemplates Type = "payment_plan_templates" // Represents payment plan templates
PaymentMethods Type = "payment_methods" // Represents payment methods service
Permissions Type = "permissions" // Represents permissiosns service
Policies Type = "policies" // Represents access control policies
PolicyAssignements Type = "policy_assignments" // Represents policy assignments database
Recipients Type = "recipients" // Represents payment recipients
RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication
Roles Type = "roles" // Represents roles in access control
Storage Type = "storage" // Represents statuses of tasks or projects
Tenants Type = "tenants" // Represents tenants managed in the system
Wallets Type = "wallets" // Represents workflows for tasks or projects
Workflows Type = "workflows" // Represents workflows for tasks or projects
PaymentMethods Type = "payment_methods" // Represents payment methods service
Permissions Type = "permissions" // Represents permissiosns service
Policies Type = "policies" // Represents access control policies
PolicyAssignements Type = "policy_assignments" // Represents policy assignments database
Recipients Type = "recipients" // Represents payment recipients
RefreshTokens Type = "refresh_tokens" // Represents refresh tokens for authentication
Roles Type = "roles" // Represents roles in access control
Storage Type = "storage" // Represents statuses of tasks or projects
Tenants Type = "tenants" // Represents tenants managed in the system
Wallets Type = "wallets" // Represents workflows for tasks or projects
Workflows Type = "workflows" // Represents workflows for tasks or projects
)
func StringToSType(s string) (Type, error) {

View File

@@ -128,7 +128,7 @@ message Account {
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
AccountState state = 4;
string label = 5;
string owner_ref = 6;
string owner_ref = 6; // optional account_ref; empty means organization-owned
google.protobuf.Struct provider_details = 7;
google.protobuf.Timestamp created_at = 8;
google.protobuf.Timestamp updated_at = 9;
@@ -188,7 +188,7 @@ message OpenAccountRequest {
AccountKind kind = 2;
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
string label = 4;
string owner_ref = 5;
string owner_ref = 5; // optional account_ref; empty means organization-owned
google.protobuf.Struct params = 6;
string correlation_id = 7;
string parent_intent_id = 8;
@@ -212,6 +212,7 @@ message ListAccountsRequest {
AccountKind kind = 2;
string asset = 3; // canonical asset string (USD, ETH, USDT-TRC20)
common.pagination.v1.CursorPageRequest page = 4;
string organization_ref = 5; // optional org scope (preferred over owner_ref)
}
message ListAccountsResponse {

View File

@@ -70,14 +70,15 @@ message PostingLine {
message CreateAccountRequest {
string organization_ref = 1;
string account_code = 2;
AccountType account_type = 3;
string currency = 4;
AccountStatus status = 5;
bool allow_negative = 6;
bool is_settlement = 7;
map<string, string> metadata = 8;
common.describable.v1.Describable describable = 9;
string owner_ref = 2;
string account_code = 3;
AccountType account_type = 4;
string currency = 5;
AccountStatus status = 6;
bool allow_negative = 7;
bool is_settlement = 8;
map<string, string> metadata = 9;
common.describable.v1.Describable describable = 10;
}
message CreateAccountResponse {

View File

@@ -33,7 +33,8 @@ type CreateLedgerAccount struct {
AllowNegative bool `json:"allowNegative,omitempty"`
IsSettlement bool `json:"isSettlement,omitempty"`
Metadata map[string]string `json:"metadata,omitempty"`
Describable *model.Describable `json:"describable,omitempty"`
Describable model.Describable `json:"describable"`
IsOrgWallet bool `json:"isOrgWallet"`
}
func (r *CreateLedgerAccount) Validate() error {

View File

@@ -0,0 +1,9 @@
package srequest
import "github.com/tech/sendico/pkg/model"
type CreateWallet struct {
Description model.Describable `json:"description"`
IsOrgWallet bool `json:"isOrgWallet"`
Asset model.ChainAssetKey `json:"asset"`
}

View File

@@ -0,0 +1,45 @@
package proto
import (
"fmt"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
)
func Network2Proto(network model.ChainNetwork) (chainv1.ChainNetwork, error) {
switch network {
case model.ChainNetworkARB:
return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE, nil
case model.ChainNetworkEthMain:
return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET, nil
case model.ChainNetworkTronMain:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, nil
case model.ChainNetworkTronNile:
return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE, nil
case model.ChainNetworkUnspecified:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, nil
default:
return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED, merrors.InvalidArgument(fmt.Sprintf("Unkwnown chain network value '%s'", network), "network")
}
}
func Asset2Proto(asset *model.ChainAsset) (*chainv1.Asset, error) {
if asset == nil {
return nil, merrors.InvalidArgument("Asset must be provided", "asset")
}
netw, err := Network2Proto(asset.Chain)
if err != nil {
return nil, err
}
var contract string
if asset.ContractAddress != nil {
contract = *asset.ContractAddress
}
return &chainv1.Asset{
Chain: netw,
TokenSymbol: asset.TokenSymbol,
ContractAddress: contract,
}, nil
}

View File

@@ -70,7 +70,7 @@ func (a *AccountAPI) signup(r *http.Request) http.HandlerFunc {
var sr srequest.Signup
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode signup request", zap.Error(err))
return response.BadRequest(a.logger, a.Name(), "", err.Error())
return response.BadPayload(a.logger, a.Name(), err)
}
sr.Account.Login = strings.ToLower(strings.TrimSpace(sr.Account.Login))
@@ -252,12 +252,11 @@ func (a *AccountAPI) openOrgWallet(ctx context.Context, org *model.Organization,
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: org.ID.Hex(),
OwnerRef: org.ID.Hex(),
Describable: &describablev1.Describable{
Name: sr.CryptoWallet.Name,
Description: sr.CryptoWallet.Description,
},
Asset: a.chainAsset,
Asset: a.chainAsset,
Metadata: map[string]string{
"source": "signup",
"login": sr.Account.Login,

View File

@@ -56,25 +56,28 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
}
var describable *describablev1.Describable
if payload.Describable != nil {
name := strings.TrimSpace(payload.Describable.Name)
var description *string
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed != "" {
description = &trimmed
}
name := strings.TrimSpace(payload.Describable.Name)
var description *string
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed != "" {
description = &trimmed
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
if name != "" || description != nil {
describable = &describablev1.Describable{
Name: name,
Description: description,
}
}
var ownerRef string
if !payload.IsOrgWallet {
ownerRef = account.ID.Hex()
}
resp, err := a.client.CreateAccount(ctx, &ledgerv1.CreateAccountRequest{
OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef,
AccountCode: payload.AccountCode,
AccountType: accountType,
Currency: payload.Currency,
@@ -95,24 +98,19 @@ func (a *LedgerAPI) createAccount(r *http.Request, account *model.Account, token
func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAccount, error) {
defer r.Body.Close()
payload := &srequest.CreateLedgerAccount{}
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
payload := srequest.CreateLedgerAccount{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
}
payload.AccountCode = strings.TrimSpace(payload.AccountCode)
payload.Currency = strings.ToUpper(strings.TrimSpace(payload.Currency))
if payload.Describable != nil {
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed == "" {
payload.Describable.Description = nil
} else {
payload.Describable.Description = &trimmed
}
}
if payload.Describable.Name == "" && payload.Describable.Description == nil {
payload.Describable = nil
payload.Describable.Name = strings.TrimSpace(payload.Describable.Name)
if payload.Describable.Description != nil {
trimmed := strings.TrimSpace(*payload.Describable.Description)
if trimmed == "" {
payload.Describable.Description = nil
} else {
payload.Describable.Description = &trimmed
}
}
if len(payload.Metadata) == 0 {
@@ -121,7 +119,7 @@ func decodeLedgerAccountCreatePayload(r *http.Request) (*srequest.CreateLedgerAc
if err := payload.Validate(); err != nil {
return nil, err
}
return payload, nil
return &payload, nil
}
func mapLedgerAccountType(accountType srequest.LedgerAccountType) (ledgerv1.AccountType, error) {

View File

@@ -0,0 +1,98 @@
package walletapiimp
import (
"encoding/json"
"net/http"
"strings"
"github.com/google/uuid"
"github.com/tech/sendico/pkg/api/http/response"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
"github.com/tech/sendico/pkg/mutil/mzap"
describablev1 "github.com/tech/sendico/pkg/proto/common/describable/v1"
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
"github.com/tech/sendico/server/interface/api/srequest"
"github.com/tech/sendico/server/interface/api/sresponse"
mutil "github.com/tech/sendico/server/internal/mutil/param"
ast "github.com/tech/sendico/server/internal/mutil/proto"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
)
func (a *WalletAPI) create(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
a.logger.Warn("Failed to parse organization reference for wallet list", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(r)))
return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err)
}
var sr srequest.CreateWallet
if err := json.NewDecoder(r.Body).Decode(&sr); err != nil {
a.logger.Warn("Failed to decode wallet creation request request", zap.Error(err), mzap.StorableRef(account))
return response.BadPayload(a.logger, a.Name(), err)
}
ctx := r.Context()
res, err := a.enf.Enforce(ctx, a.walletsPermissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionCreate)
if err != nil {
a.logger.Warn("Failed to check chain wallet access permissions", zap.Error(err), mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if !res {
a.logger.Debug("Access denied when listing organization wallets", mutil.PLog(a.oph, r), mzap.StorableRef(account))
return response.AccessDenied(a.logger, a.Name(), "wallets creation permission denied")
}
asset, err := a.assets.Resolve(ctx, sr.Asset)
if err != nil {
a.logger.Warn("Failed to resolve asset", zap.Error(err), mzap.StorableRef(account),
zap.String("chain", string(sr.Asset.Chain)), zap.String("token", sr.Asset.TokenSymbol))
return response.Auto(a.logger, a.Name(), err)
}
if a.chainGateway == nil {
return response.Internal(a.logger, mservice.ChainGateway, merrors.Internal("chain gateway client is not configured"))
}
var ownerRef string
if !sr.IsOrgWallet {
ownerRef = account.ID.Hex()
}
passet, err := ast.Asset2Proto(&asset.Asset)
if err != nil {
a.logger.Warn("Failed to convert asset to proto asset", zap.Error(err),
mzap.StorableRef(asset), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
req := &chainv1.CreateManagedWalletRequest{
IdempotencyKey: uuid.NewString(),
OrganizationRef: orgRef.Hex(),
OwnerRef: ownerRef,
Describable: &describablev1.Describable{
Name: sr.Description.Name,
Description: sr.Description.Description,
},
Asset: passet,
Metadata: map[string]string{
"source": "create",
"login": account.Login,
},
}
resp, err := a.chainGateway.CreateManagedWallet(ctx, req)
if err != nil {
a.logger.Warn("Failed to create managed wallet", zap.Error(err), mzap.ObjRef("organization_ref", orgRef), mzap.StorableRef(account))
return response.Auto(a.logger, a.Name(), err)
}
if resp == nil || resp.Wallet == nil || strings.TrimSpace(resp.Wallet.WalletRef) == "" {
return response.Auto(a.logger, a.Name(), merrors.Internal("chain gateway returned empty wallet reference"))
}
a.logger.Info("Managed wallet created for organization", mzap.ObjRef("organization_ref", orgRef),
zap.String("wallet_ref", resp.Wallet.WalletRef), mzap.StorableRef(account))
return sresponse.Success(a.logger, token)
}

View File

@@ -10,6 +10,7 @@ import (
chaingatewayclient "github.com/tech/sendico/gateway/chain/client"
api "github.com/tech/sendico/pkg/api/http"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/db/chainassets"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/mservice"
@@ -28,9 +29,11 @@ type WalletAPI struct {
wph mutil.ParamHelper
walletsPermissionRef primitive.ObjectID
balancesPermissionRef primitive.ObjectID
assets chainassets.DB
}
type chainWalletClient interface {
CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error)
ListManagedWallets(ctx context.Context, req *chainv1.ListManagedWalletsRequest) (*chainv1.ListManagedWalletsResponse, error)
GetWalletBalance(ctx context.Context, req *chainv1.GetWalletBalanceRequest) (*chainv1.GetWalletBalanceResponse, error)
Close() error
@@ -55,6 +58,12 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
wph: mutil.CreatePH(mservice.Wallets),
}
var err error
if p.assets, err = apiCtx.DBFactory().NewChainAsstesDB(); err != nil {
p.logger.Warn("Failed to create asstes db", zap.Error(err))
return nil, err
}
walletsPolicy, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.ChainWallets)
if err != nil {
p.logger.Warn("Failed to fetch chain wallets permission policy description", zap.Error(err))
@@ -81,6 +90,7 @@ func CreateAPI(apiCtx eapi.API) (*WalletAPI, error) {
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listWallets)
apiCtx.Register().AccountHandler(p.Name(), p.wph.AddRef(p.oph.AddRef("/"))+"/balance", api.Get, p.getWalletBalance)
apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Post, p.create)
return p, nil
}

View File

@@ -191,6 +191,15 @@ class AccountProvider extends ChangeNotifier {
}
}
Future<void> resendVerificationEmail(String email) async {
try {
await AccountService.resendVerificationEmail(email);
} catch (e) {
_setResource(_resource.copyWith(error: toException(e)));
rethrow;
}
}
Future<void> logout() async {
_authState = AuthState.empty;
_setResource(_resource.copyWith(isLoading: true, error: null));

View File

@@ -57,6 +57,11 @@ class AccountService {
await getPUTResponse(_objectType, 'password', ForgotPasswordRequest.build(login: email).toJson());
}
static Future<void> resendVerificationEmail(String email) async {
_logger.fine('Resending verification email');
await getPUTResponse(_objectType, 'email', {'login': email});
}
static Future<void> resetPassword(String accountRef, String token, String newPassword) async {
_logger.fine('Resetting password for account: $accountRef');
await getPOSTResponse(_objectType, 'password/reset/$accountRef/$token', ResetPasswordRequest.build(password: newPassword).toJson());

View File

@@ -9,6 +9,7 @@ enum Pages {
methods,
verify,
signup,
signupConfirm,
settings,
dashboard,
profile,

View File

@@ -8,6 +8,8 @@ import 'package:pweb/pages/2fa/page.dart';
import 'package:pweb/pages/errors/not_found.dart';
import 'package:pweb/pages/login/page.dart';
import 'package:pweb/pages/signup/page.dart';
import 'package:pweb/pages/signup/confirmation/args.dart';
import 'package:pweb/pages/signup/confirmation/page.dart';
import 'package:pweb/pages/verification/page.dart';
@@ -36,6 +38,15 @@ GoRouter createRouter() => GoRouter(
path: routerPage(Pages.signup),
builder: (_, __) => const SignUpPage(),
),
GoRoute(
name: Pages.signupConfirm.name,
path: routerPage(Pages.signupConfirm),
builder: (_, state) => SignUpConfirmationPage(
email: state.extra is SignupConfirmationArgs
? (state.extra as SignupConfirmationArgs).email
: null,
),
),
GoRoute(
name: Pages.verify.name,
path: '${routerPage(Pages.verify)}${routerAddParam(PageParams.token)}',

View File

@@ -41,6 +41,30 @@
"goToSignUp": "Go to Sign Up",
"signupError": "Failed to signup: {error}",
"signupSuccess": "Email confirmation message has been sent to {email}. Please, open it and click link to activate your account.",
"signupConfirmationTitle": "Confirm your email",
"signupConfirmationDescription": "We sent a confirmation link to {email}. Open it and click the link to activate your account.",
"@signupConfirmationDescription": {
"placeholders": {
"email": {}
}
},
"signupConfirmationDescriptionNoEmail": "We sent a confirmation link to your email. Open it and click the link to activate your account.",
"signupConfirmationResend": "Resend email",
"signupConfirmationResendCooldown": "Resend in {time}",
"@signupConfirmationResendCooldown": {
"placeholders": {
"time": {}
}
},
"signupConfirmationResent": "Verification email resent to {email}.",
"@signupConfirmationResent": {
"placeholders": {
"email": {}
}
},
"signupConfirmationResendError": "Failed to resend verification email",
"signupConfirmationLoginTitle": "Log in after confirmation",
"signupConfirmationLoginHint": "Once your email is confirmed, you can sign in below.",
"connectivityError": "Cannot reach the server at {serverAddress}. Check your network and try again.",
"errorAccountNotVerified": "Your account hasn't been verified yet. Please check your email to complete the verification",
"errorLoginUnauthorized": "Login or password is incorrect. Please try again",

View File

@@ -41,6 +41,30 @@
"goToSignUp": "Перейти к регистрации",
"signupError": "Не удалось зарегистрироваться: {error}",
"signupSuccess": "Письмо с подтверждением email отправлено на {email}. Пожалуйста, откройте его и перейдите по ссылке для активации вашего аккаунта.",
"signupConfirmationTitle": "Подтвердите email",
"signupConfirmationDescription": "Мы отправили ссылку для подтверждения на {email}. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.",
"@signupConfirmationDescription": {
"placeholders": {
"email": {}
}
},
"signupConfirmationDescriptionNoEmail": "Мы отправили ссылку для подтверждения на ваш email. Откройте письмо и перейдите по ссылке, чтобы активировать аккаунт.",
"signupConfirmationResend": "Отправить письмо снова",
"signupConfirmationResendCooldown": "Повторная отправка через {time}",
"@signupConfirmationResendCooldown": {
"placeholders": {
"time": {}
}
},
"signupConfirmationResent": "Письмо с подтверждением повторно отправлено на {email}.",
"@signupConfirmationResent": {
"placeholders": {
"email": {}
}
},
"signupConfirmationResendError": "Не удалось повторно отправить письмо с подтверждением",
"signupConfirmationLoginTitle": "Войдите после подтверждения",
"signupConfirmationLoginHint": "После подтверждения email вы можете войти ниже.",
"connectivityError": "Не удается связаться с сервером {serverAddress}. Проверьте ваше интернет-соединение и попробуйте снова.",
"errorAccountNotVerified": "Ваш аккаунт еще не подтвержден. Пожалуйста, проверьте вашу электронную почту для завершения верификации",
"errorLoginUnauthorized": "Неверный логин или пароль. Пожалуйста, попробуйте снова",

View File

@@ -22,7 +22,9 @@ import 'package:pweb/generated/i18n/app_localizations.dart';
class LoginForm extends StatefulWidget {
const LoginForm({super.key});
final String? initialEmail;
const LoginForm({super.key, this.initialEmail});
@override
State<LoginForm> createState() => _LoginFormState();
@@ -37,6 +39,16 @@ class _LoginFormState extends State<LoginForm> {
final ValueNotifier<bool> _isUsernameAcceptable = ValueNotifier<bool>(false);
final ValueNotifier<bool> _isPasswordAcceptable = ValueNotifier<bool>(false);
@override
void initState() {
super.initState();
final initialEmail = widget.initialEmail?.trim();
if (initialEmail != null && initialEmail.isNotEmpty) {
_usernameController.text = initialEmail;
_isUsernameAcceptable.value = true;
}
}
Future<String?> _login(BuildContext context, VoidCallback onLogin, void Function(Object e) onError) async {
final provider = Provider.of<AccountProvider>(context, listen: false);

View File

@@ -0,0 +1,5 @@
class SignupConfirmationArgs {
final String? email;
const SignupConfirmationArgs({this.email});
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/account.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SignupConfirmationCard extends StatefulWidget {
final String? email;
const SignupConfirmationCard({super.key, this.email});
@override
State<SignupConfirmationCard> createState() => _SignupConfirmationCardState();
}
class _SignupConfirmationCardState extends State<SignupConfirmationCard> {
static const int _defaultCooldownSeconds = 60;
Timer? _cooldownTimer;
int _cooldownRemainingSeconds = 0;
bool _isResending = false;
@override
void initState() {
super.initState();
_startCooldown(_defaultCooldownSeconds);
}
@override
void dispose() {
_cooldownTimer?.cancel();
super.dispose();
}
bool get _isCooldownActive => _cooldownRemainingSeconds > 0;
void _startCooldown(int seconds) {
_cooldownTimer?.cancel();
if (seconds <= 0) {
setState(() => _cooldownRemainingSeconds = 0);
return;
}
setState(() => _cooldownRemainingSeconds = seconds);
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) {
timer.cancel();
return;
}
if (_cooldownRemainingSeconds <= 1) {
timer.cancel();
setState(() => _cooldownRemainingSeconds = 0);
return;
}
setState(() => _cooldownRemainingSeconds -= 1);
});
}
Future<void> _resendVerificationEmail() async {
final email = widget.email?.trim();
final locs = AppLocalizations.of(context)!;
if (email == null || email.isEmpty) {
notifyUser(context, locs.errorEmailMissing);
return;
}
if (_isResending || _isCooldownActive) return;
setState(() => _isResending = true);
try {
await context.read<AccountProvider>().resendVerificationEmail(email);
if (!mounted) return;
notifyUser(context, locs.signupConfirmationResent(email));
_startCooldown(_defaultCooldownSeconds);
} catch (e) {
if (!mounted) return;
postNotifyUserOfErrorX(
context: context,
errorSituation: locs.signupConfirmationResendError,
exception: e,
);
} finally {
if (mounted) {
setState(() => _isResending = false);
}
}
}
String _formatCooldown(int seconds) {
final minutes = seconds ~/ 60;
final remainingSeconds = seconds % 60;
if (minutes > 0) {
return '$minutes:${remainingSeconds.toString().padLeft(2, '0')}';
}
return remainingSeconds.toString();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locs = AppLocalizations.of(context)!;
final email = widget.email?.trim();
final description = (email != null && email.isNotEmpty)
? locs.signupConfirmationDescription(email)
: locs.signupConfirmationDescriptionNoEmail;
final canResend = !_isResending && !_isCooldownActive && email != null && email.isNotEmpty;
final resendLabel = _isCooldownActive
? locs.signupConfirmationResendCooldown(_formatCooldown(_cooldownRemainingSeconds))
: locs.signupConfirmationResend;
return Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locs.signupConfirmationTitle,
style: theme.textTheme.headlineSmall,
),
const VSpacer(),
Text(description, style: theme.textTheme.bodyMedium),
if (email != null && email.isNotEmpty) ...[
const VSpacer(),
SelectableText(
email,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const VSpacer(multiplier: 1.5),
Row(
children: [
ElevatedButton.icon(
onPressed: canResend ? _resendVerificationEmail : null,
icon: _isResending
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: const Icon(Icons.mark_email_read_outlined),
label: Text(resendLabel),
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:pweb/widgets/vspacer.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class SignupConfirmationLoginPrompt extends StatelessWidget {
const SignupConfirmationLoginPrompt({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final locs = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
locs.signupConfirmationLoginTitle,
style: theme.textTheme.titleLarge,
),
const VSpacer(),
Text(
locs.signupConfirmationLoginHint,
style: theme.textTheme.bodyMedium,
),
],
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/login/app_bar.dart';
import 'package:pweb/pages/login/form.dart';
import 'package:pweb/pages/signup/confirmation/card.dart';
import 'package:pweb/pages/signup/confirmation/login_prompt.dart';
import 'package:pweb/pages/with_footer.dart';
import 'package:pweb/widgets/vspacer.dart';
class SignUpConfirmationPage extends StatefulWidget {
final String? email;
const SignUpConfirmationPage({super.key, this.email});
@override
State<SignUpConfirmationPage> createState() => _SignUpConfirmationPageState();
}
class _SignUpConfirmationPageState extends State<SignUpConfirmationPage> {
@override
Widget build(BuildContext context) {
final email = widget.email?.trim();
return PageWithFooter(
appBar: const LoginAppBar(),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: SignupConfirmationCard(email: email),
),
),
const VSpacer(multiplier: 2),
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: const SignupConfirmationLoginPrompt(),
),
),
const VSpacer(multiplier: 1.5),
LoginForm(initialEmail: email),
],
),
);
}
}

View File

@@ -15,7 +15,7 @@ import 'package:pweb/app/router/pages.dart';
import 'package:pweb/pages/signup/form/content.dart';
import 'package:pweb/pages/signup/form/controllers.dart';
import 'package:pweb/pages/signup/form/form.dart';
import 'package:pweb/utils/snackbar.dart';
import 'package:pweb/pages/signup/confirmation/args.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
@@ -91,12 +91,12 @@ class SignUpFormState extends State<SignUpForm> {
void handleSignUp() => signUp(
context,
() {
final locs = AppLocalizations.of(context)!;
notifyUser(
context,
locs.signupSuccess(controllers.email.text.trim()),
context.goNamed(
Pages.signupConfirm.name,
extra: SignupConfirmationArgs(
email: controllers.email.text.trim(),
),
);
context.goNamed(Pages.login.name);
},
(e) => postNotifyUserOfErrorX(
context: context,