3 Commits

Author SHA1 Message Date
Arseni
5447433b5d Sender Invitation 2026-01-12 21:28:18 +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
53 changed files with 1975 additions and 112 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

@@ -0,0 +1,22 @@
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/invitation/invitation.dart';
part 'invitations.g.dart';
@JsonSerializable(explicitToJson: true)
class InvitationsResponse extends BaseAuthorizedResponse {
final List<InvitationDTO> invitations;
const InvitationsResponse({
required super.accessToken,
required this.invitations,
});
factory InvitationsResponse.fromJson(Map<String, dynamic> json) => _$InvitationsResponseFromJson(json);
@override
Map<String, dynamic> toJson() => _$InvitationsResponseToJson(this);
}

View File

@@ -0,0 +1,56 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pshared/data/dto/date_time.dart';
import 'package:pshared/data/dto/permissions/bound.dart';
part 'invitation.g.dart';
@JsonSerializable()
class InvitationContentDTO {
final String email;
final String name;
final String comment;
const InvitationContentDTO({
required this.email,
required this.name,
required this.comment,
});
factory InvitationContentDTO.fromJson(Map<String, dynamic> json) => _$InvitationContentDTOFromJson(json);
Map<String, dynamic> toJson() => _$InvitationContentDTOToJson(this);
}
@JsonSerializable(explicitToJson: true)
class InvitationDTO extends PermissionBoundDTO {
final String roleRef;
final String inviterRef;
final String status;
@UtcIso8601Converter()
final DateTime expiresAt;
@JsonKey(name: 'description')
final InvitationContentDTO content;
@JsonKey(defaultValue: false)
final bool isArchived;
const InvitationDTO({
required super.id,
required super.createdAt,
required super.updatedAt,
required super.permissionRef,
required super.organizationRef,
required this.roleRef,
required this.inviterRef,
required this.status,
required this.expiresAt,
required this.content,
this.isArchived = false,
});
factory InvitationDTO.fromJson(Map<String, dynamic> json) => _$InvitationDTOFromJson(json);
@override
Map<String, dynamic> toJson() => _$InvitationDTOToJson(this);
}

View File

@@ -0,0 +1,78 @@
import 'package:pshared/data/dto/invitation/invitation.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/storable.dart';
extension InvitationModelMapper on Invitation {
InvitationDTO toDTO() => InvitationDTO(
id: storable.id,
createdAt: storable.createdAt,
updatedAt: storable.updatedAt,
permissionRef: permissionBound.permissionRef,
organizationRef: permissionBound.organizationRef,
roleRef: roleRef,
inviterRef: inviterRef,
status: _statusToValue(status),
expiresAt: expiresAt,
content: InvitationContentDTO(
email: content.email,
name: content.name,
comment: content.comment,
),
isArchived: isArchived,
);
}
extension InvitationDTOMapper on InvitationDTO {
Invitation toDomain() => Invitation(
storable: newStorable(id: id, createdAt: createdAt, updatedAt: updatedAt),
permissionBound: newPermissionBound(
organizationBound: newOrganizationBound(organizationRef: organizationRef),
permissionRef: permissionRef,
),
roleRef: roleRef,
inviterRef: inviterRef,
status: _statusFromValue(status),
expiresAt: expiresAt.toUtc(),
content: InvitationContent(
email: content.email,
name: content.name,
comment: content.comment,
),
isArchived: isArchived,
);
}
InvitationStatus _statusFromValue(String value) {
switch (value) {
case 'sent':
return InvitationStatus.sent;
case 'accepted':
return InvitationStatus.accepted;
case 'declined':
return InvitationStatus.declined;
case 'revoked':
return InvitationStatus.revoked;
case 'created':
default:
return InvitationStatus.created;
}
}
String _statusToValue(InvitationStatus status) {
switch (status) {
case InvitationStatus.sent:
return 'sent';
case InvitationStatus.accepted:
return 'accepted';
case InvitationStatus.declined:
return 'declined';
case InvitationStatus.revoked:
return 'revoked';
case InvitationStatus.created:
return 'created';
}
}

View File

@@ -0,0 +1,111 @@
import 'package:pshared/models/organization/bound.dart';
import 'package:pshared/models/permissions/bound.dart';
import 'package:pshared/models/permissions/bound/storable.dart';
import 'package:pshared/models/storable.dart';
import 'package:pshared/models/invitation/status.dart';
class InvitationContent {
final String email;
final String name;
final String comment;
const InvitationContent({
required this.email,
required this.name,
required this.comment,
});
InvitationContent copyWith({
String? email,
String? name,
String? comment,
}) => InvitationContent(
email: email ?? this.email,
name: name ?? this.name,
comment: comment ?? this.comment,
);
}
class Invitation implements PermissionBoundStorable {
final Storable storable;
final PermissionBound permissionBound;
final String roleRef;
final String inviterRef;
final InvitationStatus status;
final DateTime expiresAt;
final InvitationContent content;
final bool isArchived;
Invitation({
required this.storable,
required this.permissionBound,
required this.roleRef,
required this.inviterRef,
required this.status,
required this.expiresAt,
required this.content,
this.isArchived = false,
});
@override
String get id => storable.id;
@override
DateTime get createdAt => storable.createdAt;
@override
DateTime get updatedAt => storable.updatedAt;
@override
String get permissionRef => permissionBound.permissionRef;
@override
String get organizationRef => permissionBound.organizationRef;
String get inviteeDisplayName => content.name.isNotEmpty ? content.name : content.email;
bool get isExpired => expiresAt.isBefore(DateTime.now().toUtc());
bool get isPending => status == InvitationStatus.created || status == InvitationStatus.sent;
Invitation copyWith({
Storable? storable,
PermissionBound? permissionBound,
String? roleRef,
String? inviterRef,
InvitationStatus? status,
DateTime? expiresAt,
InvitationContent? content,
bool? isArchived,
}) => Invitation(
storable: storable ?? this.storable,
permissionBound: permissionBound ?? this.permissionBound,
roleRef: roleRef ?? this.roleRef,
inviterRef: inviterRef ?? this.inviterRef,
status: status ?? this.status,
expiresAt: expiresAt ?? this.expiresAt,
content: content ?? this.content,
isArchived: isArchived ?? this.isArchived,
);
}
Invitation newInvitation({
required String organizationRef,
required String roleRef,
required String inviterRef,
required String email,
String name = '',
String comment = '',
InvitationStatus status = InvitationStatus.created,
DateTime? expiresAt,
bool isArchived = false,
String? permissionRef,
}) => Invitation(
storable: newStorable(),
permissionBound: newPermissionBound(
organizationBound: newOrganizationBound(organizationRef: organizationRef),
permissionRef: permissionRef,
),
roleRef: roleRef,
inviterRef: inviterRef,
status: status,
expiresAt: expiresAt ?? DateTime.now().toUtc().add(const Duration(days: 7)),
content: InvitationContent(email: email, name: name, comment: comment),
isArchived: isArchived,
);

View File

@@ -0,0 +1,7 @@
enum InvitationStatus {
created,
sent,
accepted,
declined,
revoked,
}

View File

@@ -0,0 +1,64 @@
import 'package:pshared/data/mapper/invitation/invitation.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/template.dart';
import 'package:pshared/service/invitation/service.dart';
class InvitationsProvider extends GenericProvider<Invitation> {
InvitationsProvider() : super(service: InvitationService.basicService);
late OrganizationsProvider _organizations;
String? _loadedOrganizationId;
List<Invitation> get invitations => List<Invitation>.unmodifiable(items);
void updateProviders(OrganizationsProvider organizations) {
_organizations = organizations;
if (_organizations.isOrganizationSet) {
final organizationId = _organizations.current.id;
if (_loadedOrganizationId != organizationId) {
_loadedOrganizationId = organizationId;
load(organizationId, organizationId);
}
}
}
Future<Invitation> sendInvitation({
required String email,
required String roleRef,
required String inviterRef,
String name = '',
String comment = '',
DateTime? expiresAt,
}) async {
final invitation = newInvitation(
organizationRef: _organizations.current.id,
roleRef: roleRef,
inviterRef: inviterRef,
email: email,
name: name,
comment: comment,
expiresAt: expiresAt,
);
return createObject(_organizations.current.id, invitation.toDTO().toJson());
}
Future<void> updateInvitation(Invitation invitation) {
return update(invitation.toDTO().toJson());
}
Future<void> revokeInvitation(Invitation invitation) {
return updateInvitation(invitation.copyWith(status: InvitationStatus.revoked));
}
Future<void> setInvitationArchived(Invitation invitation, bool archived) {
return setArchived(
organizationRef: _organizations.current.id,
objectRef: invitation.id,
newIsArchived: archived,
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:pshared/api/responses/invitations.dart';
import 'package:pshared/data/mapper/invitation/invitation.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/service/services.dart';
import 'package:pshared/service/template.dart';
class InvitationService {
static const String _objectType = Services.invitations;
static final BasicService<Invitation> _basicService = BasicService<Invitation>(
objectType: _objectType,
fromJson: (json) => InvitationsResponse.fromJson(json).invitations.map((dto) => dto.toDomain()).toList(),
);
static BasicService<Invitation> get basicService => _basicService;
static Future<List<Invitation>> list(String organizationRef, String parentRef) {
return _basicService.list(organizationRef, parentRef);
}
static Future<List<Invitation>> create(String organizationRef, Invitation invitation) {
return _basicService.create(organizationRef, invitation.toDTO().toJson());
}
static Future<List<Invitation>> update(Invitation invitation) {
return _basicService.update(invitation.toDTO().toJson());
}
static Future<List<Invitation>> delete(Invitation invitation) {
return _basicService.delete(invitation.id);
}
static Future<List<Invitation>> archive({
required String organizationRef,
required Invitation invitation,
required bool archived,
}) {
return _basicService.archive(
organizationRef: organizationRef,
objectRef: invitation.id,
newIsArchived: archived,
);
}
}

View File

@@ -13,6 +13,7 @@ class PayoutRoutes {
static const dashboard = 'dashboard';
static const sendPayout = payment;
static const recipients = 'payout-recipients';
static const invitations = 'payout-invitations';
static const addRecipient = 'payout-add-recipient';
static const payment = 'payout-payment';
static const settings = 'payout-settings';
@@ -26,6 +27,7 @@ class PayoutRoutes {
static const dashboardPath = '/dashboard';
static const recipientsPath = '/dashboard/recipients';
static const invitationsPath = '/dashboard/invitations';
static const addRecipientPath = '/dashboard/recipients/add';
static const paymentPath = '/dashboard/payment';
static const settingsPath = '/dashboard/settings';
@@ -39,14 +41,16 @@ class PayoutRoutes {
case PayoutDestination.dashboard:
return dashboard;
case PayoutDestination.sendPayout:
return payment;
case PayoutDestination.recipients:
return recipients;
case PayoutDestination.addrecipient:
return addRecipient;
case PayoutDestination.payment:
return payment;
case PayoutDestination.settings:
return payment;
case PayoutDestination.recipients:
return recipients;
case PayoutDestination.invitations:
return invitations;
case PayoutDestination.addrecipient:
return addRecipient;
case PayoutDestination.payment:
return payment;
case PayoutDestination.settings:
return settings;
case PayoutDestination.reports:
return reports;
@@ -64,14 +68,16 @@ class PayoutRoutes {
case PayoutDestination.dashboard:
return dashboardPath;
case PayoutDestination.sendPayout:
return paymentPath;
case PayoutDestination.recipients:
return recipientsPath;
case PayoutDestination.addrecipient:
return addRecipientPath;
case PayoutDestination.payment:
return paymentPath;
case PayoutDestination.settings:
return paymentPath;
case PayoutDestination.recipients:
return recipientsPath;
case PayoutDestination.invitations:
return invitationsPath;
case PayoutDestination.addrecipient:
return addRecipientPath;
case PayoutDestination.payment:
return paymentPath;
case PayoutDestination.settings:
return settingsPath;
case PayoutDestination.reports:
return reportsPath;
@@ -89,13 +95,15 @@ class PayoutRoutes {
case dashboard:
return PayoutDestination.dashboard;
case sendPayout:
return PayoutDestination.payment;
case recipients:
return PayoutDestination.recipients;
case addRecipient:
return PayoutDestination.addrecipient;
case settings:
return PayoutDestination.settings;
return PayoutDestination.payment;
case recipients:
return PayoutDestination.recipients;
case invitations:
return PayoutDestination.invitations;
case addRecipient:
return PayoutDestination.addrecipient;
case settings:
return PayoutDestination.settings;
case reports:
return PayoutDestination.reports;
case methods:
@@ -174,4 +182,4 @@ extension PayoutNavigation on BuildContext {
PayoutRoutes.editWallet,
queryParameters: PayoutRoutes.buildQueryParameters(returnTo: returnTo),
);
}
}

View File

@@ -14,6 +14,7 @@ import 'package:pshared/models/payment/wallet.dart';
import 'package:pweb/pages/address_book/form/page.dart';
import 'package:pweb/pages/address_book/page/page.dart';
import 'package:pweb/pages/dashboard/dashboard.dart';
import 'package:pweb/pages/invitations/page.dart';
import 'package:pweb/pages/payment_methods/page.dart';
import 'package:pweb/pages/payout_page/page.dart';
import 'package:pweb/pages/payout_page/wallet/edit/page.dart';
@@ -86,6 +87,13 @@ RouteBase payoutShellRoute() => ShellRoute(
);
},
),
GoRoute(
name: PayoutRoutes.invitations,
path: PayoutRoutes.invitationsPath,
pageBuilder: (_, __) => const NoTransitionPage(
child: InvitationsPage(),
),
),
GoRoute(
name: PayoutRoutes.addRecipient,
path: PayoutRoutes.addRecipientPath,

View File

@@ -49,7 +49,7 @@
"errorVerificationTokenNotFound": "Account for verification not found. Sign up again",
"created": "Created",
"edited": "Edited",
"errorDataConflict": "We cant process your data because it has conflicting or contradictory information.",
"errorDataConflict": "This action conflicts with existing data. Check for duplicates or conflicting values and try again.",
"errorAccessDenied": "You do not have permission to access this resource. If you need access, please contact an administrator.",
"errorBrokenPayload": "The data you sent is invalid or incomplete. Please check your submission and try again.",
"errorInvalidArgument": "One or more arguments are invalid. Verify your input and try again.",
@@ -66,6 +66,7 @@
"showDetailsAction": "Show Details",
"errorLogin": "Error logging in",
"errorCreatingInvitation": "Failed to create invitaiton",
"errorLoadingInvitations": "Failed to load invitations",
"@errorCreatingInvitation": {
"description": "Error message displayed when invitation creation fails"
},
@@ -93,6 +94,7 @@
"payoutNavDashboard": "Dashboard",
"payoutNavSendPayout": "Send payout",
"payoutNavRecipients": "Recipients",
"payoutNavInvitations": "Invitations",
"payoutNavReports": "Reports",
"payoutNavSettings": "Settings",
"payoutNavLogout": "Logout",
@@ -185,6 +187,47 @@
"cancel": "Cancel",
"confirm": "Confirm",
"back": "Back",
"invitationsTitle": "Invite your teammates",
"invitationsSubtitle": "Send invitations for restricted employee accounts and see their status in one place.",
"invitationCreateTitle": "New invitation",
"invitationEmailLabel": "Work email",
"invitationNameLabel": "Full name",
"invitationRoleLabel": "Role",
"invitationMessageLabel": "Message (optional)",
"invitationExpiresIn": "Expires in {days} days",
"@invitationExpiresIn": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"invitationSendButton": "Send invitation",
"invitationCreatedSuccess": "Invitation sent",
"invitationSearchHint": "Search invitations",
"invitationFilterAll": "All",
"invitationFilterPending": "Pending",
"invitationFilterAccepted": "Accepted",
"invitationFilterDeclined": "Declined",
"invitationFilterRevoked": "Revoked",
"invitationFilterExpired": "Expired",
"invitationFilterArchived": "Archived",
"invitationListEmpty": "No invitations yet",
"invitationStatusPending": "Pending",
"invitationStatusAccepted": "Accepted",
"invitationStatusDeclined": "Declined",
"invitationStatusRevoked": "Revoked",
"invitationStatusExpired": "Expired",
"invitationExpires": "Expires {date}",
"invitationExpired": "Expired {date}",
"invitationInvitedBy": "Invited by",
"invitationArchiveAction": "Archive",
"invitationRevokeAction": "Revoke",
"invitationArchived": "Invitation archived",
"invitationRevoked": "Invitation revoked",
"invitationArchiveFailed": "Could not archive the invitation",
"invitationRevokeFailed": "Could not revoke the invitation",
"invitationUnknownRole": "Unknown role",
"operationfryTitle": "Operation history",
"@operationfryTitle": {

View File

@@ -49,7 +49,7 @@
"errorVerificationTokenNotFound": "Аккаунт для верификации не найден. Зарегистрируйтесь снова",
"created": "Создано",
"edited": "Изменено",
"errorDataConflict": "Мы не можем обработать ваши данные, так как они содержат конфликтующую или противоречивую информацию.",
"errorDataConflict": "Действие конфликтует с уже существующими данными. Проверьте дубликаты или противоречащие значения и попробуйте снова.",
"errorAccessDenied": "У вас нет разрешения на доступ к этому ресурсу. Если вам нужен доступ, пожалуйста, обратитесь к администратору.",
"errorBrokenPayload": "Отправленные данные недействительны или неполны. Пожалуйста, проверьте введенные данные и попробуйте снова.",
"errorInvalidArgument": "Один или несколько аргументов недействительны. Проверьте введенные данные и попробуйте снова.",
@@ -66,6 +66,7 @@
"showDetailsAction": "Показать детали",
"errorLogin": "Ошибка входа",
"errorCreatingInvitation": "Не удалось создать приглашение",
"errorLoadingInvitations": "Не удалось загрузить приглашения",
"@errorCreatingInvitation": {
"description": "Сообщение об ошибке, отображаемое при неудачном создании приглашения"
},
@@ -93,6 +94,7 @@
"payoutNavDashboard": "Дашборд",
"payoutNavSendPayout": "Отправить выплату",
"payoutNavRecipients": "Получатели",
"payoutNavInvitations": "Приглашения",
"payoutNavReports": "Отчеты",
"payoutNavSettings": "Настройки",
"payoutNavLogout": "Выйти",
@@ -185,6 +187,47 @@
"cancel": "Отмена",
"confirm": "Подтвердить",
"back": "Назад",
"invitationsTitle": "Пригласите сотрудников",
"invitationsSubtitle": "Отправляйте приглашения сотрудникам с ограниченными аккаунтами и отслеживайте статусы.",
"invitationCreateTitle": "Новое приглашение",
"invitationEmailLabel": "Рабочий email",
"invitationNameLabel": "Полное имя",
"invitationRoleLabel": "Роль",
"invitationMessageLabel": "Сообщение (необязательно)",
"invitationExpiresIn": "Истекает через {days} дн.",
"@invitationExpiresIn": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"invitationSendButton": "Отправить приглашение",
"invitationCreatedSuccess": "Приглашение отправлено",
"invitationSearchHint": "Поиск приглашений",
"invitationFilterAll": "Все",
"invitationFilterPending": "В ожидании",
"invitationFilterAccepted": "Принятые",
"invitationFilterDeclined": "Отклоненные",
"invitationFilterRevoked": "Отозванные",
"invitationFilterExpired": "Истекшие",
"invitationFilterArchived": "Архив",
"invitationListEmpty": "Пока нет приглашений",
"invitationStatusPending": "В ожидании",
"invitationStatusAccepted": "Принято",
"invitationStatusDeclined": "Отклонено",
"invitationStatusRevoked": "Отозвано",
"invitationStatusExpired": "Истекло",
"invitationExpires": "Истекает {date}",
"invitationExpired": "Истекло {date}",
"invitationInvitedBy": "Пригласил",
"invitationArchiveAction": "Архивировать",
"invitationRevokeAction": "Отозвать",
"invitationArchived": "Приглашение архивировано",
"invitationRevoked": "Приглашение отозвано",
"invitationArchiveFailed": "Не удалось архивировать приглашение",
"invitationRevokeFailed": "Не удалось отозвать приглашение",
"invitationUnknownRole": "Неизвестная роль",
"operationfryTitle": "История операций",
"@operationfryTitle": {

View File

@@ -12,6 +12,7 @@ import 'package:pshared/provider/locale.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/organizations.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/payment/amount.dart';
import 'package:pshared/provider/payment/flow.dart';
import 'package:pshared/provider/payment/provider.dart';
@@ -19,6 +20,7 @@ import 'package:pshared/provider/payment/quotation.dart';
import 'package:pshared/provider/recipient/provider.dart';
import 'package:pshared/provider/recipient/pmethods.dart';
import 'package:pshared/provider/payment/wallets.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/models/payment/type.dart';
import 'package:pshared/service/payment/wallets.dart';
@@ -72,6 +74,10 @@ void main() async {
create: (_) => PermissionsProvider(),
update: (context, orgnization, provider) => provider!..update(orgnization),
),
ChangeNotifierProxyProvider<OrganizationsProvider, EmployeesProvider>(
create: (_) => EmployeesProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
),
ChangeNotifierProvider(create: (_) => CarouselIndexProvider()),
ChangeNotifierProvider(
@@ -81,6 +87,10 @@ void main() async {
create: (_) => RecipientsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
),
ChangeNotifierProxyProvider<OrganizationsProvider, InvitationsProvider>(
create: (_) => InvitationsProvider(),
update: (context, organizations, provider) => provider!..updateProviders(organizations),
),
ChangeNotifierProxyProvider2<OrganizationsProvider, RecipientsProvider, PaymentMethodsProvider>(
create: (_) => PaymentMethodsProvider(),
update: (context, organizations, recipients, provider) => provider!..updateProviders(organizations, recipients),

View File

@@ -0,0 +1,9 @@
enum InvitationFilter {
all,
pending,
accepted,
declined,
revoked,
expired,
archived,
}

View File

@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/account.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/header.dart';
import 'package:pweb/pages/invitations/widgets/form/form.dart';
import 'package:pweb/pages/invitations/widgets/list/list.dart';
import 'package:pweb/pages/loader.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsPage extends StatefulWidget {
const InvitationsPage({super.key});
@override
State<InvitationsPage> createState() => _InvitationsPageState();
}
class _InvitationsPageState extends State<InvitationsPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _nameController = TextEditingController();
final TextEditingController _messageController = TextEditingController();
String? _selectedRoleRef;
int _expiryDays = 7;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_bootstrapRoleSelection();
}
void _bootstrapRoleSelection() {
final roles = context.read<PermissionsProvider>().roleDescriptions;
if (_selectedRoleRef == null && roles.isNotEmpty) {
_selectedRoleRef = roles.first.storable.id;
}
}
@override
void dispose() {
_emailController.dispose();
_nameController.dispose();
_messageController.dispose();
super.dispose();
}
Future<void> _sendInvitation() async {
final form = _formKey.currentState;
if (form == null || !form.validate()) return;
final account = context.read<AccountProvider>().account;
if (account == null) return;
final permissions = context.read<PermissionsProvider>();
final roleRef = _selectedRoleRef ?? permissions.roleDescriptions.firstOrNull?.storable.id;
if (roleRef == null) return;
final invitations = context.read<InvitationsProvider>();
final loc = AppLocalizations.of(context)!;
await executeActionWithNotification(
context: context,
action: () => invitations.sendInvitation(
email: _emailController.text.trim(),
name: _nameController.text.trim(),
comment: _messageController.text.trim(),
roleRef: roleRef,
inviterRef: account.id,
expiresAt: DateTime.now().toUtc().add(Duration(days: _expiryDays)),
),
successMessage: loc.invitationCreatedSuccess,
errorMessage: loc.errorCreatingInvitation,
);
_emailController.clear();
_nameController.clear();
_messageController.clear();
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final permissions = context.watch<PermissionsProvider>();
if (!permissions.canRead(ResourceType.invitations)) {
return PageViewLoader(
child: Center(child: Text(loc.errorAccessDenied)),
);
}
return PageViewLoader(
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationsHeader(loc: loc),
const SizedBox(height: 16),
InvitationsForm(
formKey: _formKey,
emailController: _emailController,
nameController: _nameController,
messageController: _messageController,
expiryDays: _expiryDays,
onExpiryChanged: (value) => setState(() => _expiryDays = value),
selectedRoleRef: _selectedRoleRef,
onRoleChanged: (role) => setState(() => _selectedRoleRef = role),
canCreate: permissions.canCreate(ResourceType.invitations),
onSubmit: _sendInvitation,
),
const SizedBox(height: 24),
const InvitationsList(),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardActions extends StatelessWidget {
final Invitation invitation;
const InvitationCardActions({super.key, required this.invitation});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (invitation.isPending && !invitation.isExpired)
TextButton.icon(
onPressed: () => _revokeInvitation(context),
icon: const Icon(Icons.block),
label: Text(loc.invitationRevokeAction),
),
if (!invitation.isArchived)
TextButton.icon(
onPressed: () => _archiveInvitation(context),
icon: const Icon(Icons.archive_outlined),
label: Text(loc.invitationArchiveAction),
),
],
);
}
Future<void> _archiveInvitation(BuildContext context) async {
final loc = AppLocalizations.of(context)!;
final provider = context.read<InvitationsProvider>();
await executeActionWithNotification(
context: context,
action: () => provider.setInvitationArchived(invitation, true),
successMessage: loc.invitationArchived,
errorMessage: loc.invitationArchiveFailed,
);
}
Future<void> _revokeInvitation(BuildContext context) async {
final loc = AppLocalizations.of(context)!;
final provider = context.read<InvitationsProvider>();
await executeActionWithNotification(
context: context,
action: () => provider.revokeInvitation(invitation),
successMessage: loc.invitationRevoked,
errorMessage: loc.invitationRevokeFailed,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/view.dart';
class InvitationsCard extends StatelessWidget {
final Invitation invitation;
const InvitationsCard({super.key, required this.invitation});
@override
Widget build(BuildContext context) => InvitationCardView(invitation: invitation);
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardDetails extends StatelessWidget {
final Invitation invitation;
final String roleLabel;
final String inviterName;
final DateFormat dateFormat;
final AppLocalizations loc;
const InvitationCardDetails({
super.key,
required this.invitation,
required this.roleLabel,
required this.inviterName,
required this.dateFormat,
required this.loc,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 20,
runSpacing: 10,
children: [
InvitationInfoRow(
icon: Icons.badge_outlined,
label: loc.invitationRoleLabel,
value: roleLabel,
),
InvitationInfoRow(
icon: Icons.schedule_outlined,
label: invitation.isExpired
? loc.invitationExpired(dateFormat.format(invitation.expiresAt.toLocal()))
: loc.invitationExpires(dateFormat.format(invitation.expiresAt.toLocal())),
value: '',
),
InvitationInfoRow(
icon: Icons.person_outline,
label: loc.invitationInvitedBy,
value: inviterName,
),
],
),
if (invitation.content.comment.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
invitation.content.comment,
style: Theme.of(context).textTheme.bodyMedium,
),
],
],
);
}
}

View File

@@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
class InvitationCardHeader extends StatelessWidget {
final Invitation invitation;
final String statusLabel;
final Color statusColor;
const InvitationCardHeader({
super.key,
required this.invitation,
required this.statusLabel,
required this.statusColor,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invitation.inviteeDisplayName,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 4),
Text(
invitation.content.email,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.hintColor),
),
],
),
),
Chip(
backgroundColor: statusColor.withAlpha(40),
label: Text(
statusLabel,
style: TextStyle(color: statusColor, fontWeight: FontWeight.w600),
),
),
],
);
}
}

View File

@@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationInfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
const InvitationInfoRow({
super.key,
required this.icon,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: theme.hintColor),
const SizedBox(width: 6),
if (value.isEmpty)
Text(label, style: theme.textTheme.bodyMedium)
else
RichText(
text: TextSpan(
style: theme.textTheme.bodyMedium,
children: [
TextSpan(text: '$label: ', style: const TextStyle(fontWeight: FontWeight.w600)),
TextSpan(text: value),
],
),
),
],
);
}
}
String invitationStatusLabel(Invitation invitation, AppLocalizations loc) {
if (invitation.isExpired && invitation.isPending) {
return loc.invitationStatusExpired;
}
switch (invitation.status) {
case InvitationStatus.created:
case InvitationStatus.sent:
return loc.invitationStatusPending;
case InvitationStatus.accepted:
return loc.invitationStatusAccepted;
case InvitationStatus.declined:
return loc.invitationStatusDeclined;
case InvitationStatus.revoked:
return loc.invitationStatusRevoked;
}
}
Color invitationStatusColor(ThemeData theme, Invitation invitation) {
if (invitation.isExpired && invitation.isPending) {
return theme.disabledColor;
}
switch (invitation.status) {
case InvitationStatus.created:
case InvitationStatus.sent:
return Colors.amber.shade800;
case InvitationStatus.accepted:
return Colors.green.shade700;
case InvitationStatus.declined:
case InvitationStatus.revoked:
return Colors.red.shade700;
}
}
String invitationRoleLabel(List<RoleDescription> roles, Invitation invitation, AppLocalizations loc) {
final role = roles.firstWhereOrNull((r) => r.storable.id == invitation.roleRef);
return role?.describable.name ?? loc.invitationUnknownRole;
}

View File

@@ -0,0 +1,72 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/resources.dart';
import 'package:pshared/provider/accounts/employees.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/card/actions.dart';
import 'package:pweb/pages/invitations/widgets/card/details.dart';
import 'package:pweb/pages/invitations/widgets/card/header.dart';
import 'package:pweb/pages/invitations/widgets/card/helpers.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationCardView extends StatelessWidget {
final Invitation invitation;
const InvitationCardView({super.key, required this.invitation});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final theme = Theme.of(context);
final permissions = context.watch<PermissionsProvider>();
final employees = context.watch<EmployeesProvider>();
final dateFormat = DateFormat.yMMMd().add_Hm();
final statusLabel = invitationStatusLabel(invitation, loc);
final statusColor = invitationStatusColor(theme, invitation);
final roleLabel = invitationRoleLabel(permissions.roleDescriptions, invitation, loc);
final inviterName = employees.getEmployee(invitation.inviterRef)?.fullName ?? loc.unknown;
final canUpdate = permissions.canUpdate(ResourceType.invitations);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
side: BorderSide(color: theme.dividerColor.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationCardHeader(
invitation: invitation,
statusLabel: statusLabel,
statusColor: statusColor,
),
const SizedBox(height: 12),
InvitationCardDetails(
invitation: invitation,
roleLabel: roleLabel,
inviterName: inviterName,
dateFormat: dateFormat,
loc: loc,
),
if (canUpdate) ...[
const SizedBox(height: 10),
InvitationCardActions(invitation: invitation),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,34 @@
import 'package:flutter/material.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFilterChips extends StatelessWidget {
final InvitationFilter selectedFilter;
final ValueChanged<InvitationFilter> onSelected;
const InvitationFilterChips({
super.key,
required this.selectedFilter,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Wrap(
spacing: 8,
runSpacing: 8,
children: InvitationFilter.values.map(
(filter) => ChoiceChip(
label: Text(invitationFilterLabel(filter, loc)),
selected: selectedFilter == filter,
onSelected: (_) => onSelected(filter),
),
).toList(),
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/models/invitation/status.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
String invitationFilterLabel(InvitationFilter filter, AppLocalizations loc) {
switch (filter) {
case InvitationFilter.all:
return loc.invitationFilterAll;
case InvitationFilter.pending:
return loc.invitationFilterPending;
case InvitationFilter.accepted:
return loc.invitationFilterAccepted;
case InvitationFilter.declined:
return loc.invitationFilterDeclined;
case InvitationFilter.revoked:
return loc.invitationFilterRevoked;
case InvitationFilter.expired:
return loc.invitationFilterExpired;
case InvitationFilter.archived:
return loc.invitationFilterArchived;
}
}
bool invitationFilterMatches(InvitationFilter filter, Invitation inv) {
switch (filter) {
case InvitationFilter.pending:
return inv.isPending && !inv.isExpired;
case InvitationFilter.accepted:
return inv.status == InvitationStatus.accepted;
case InvitationFilter.declined:
return inv.status == InvitationStatus.declined;
case InvitationFilter.revoked:
return inv.status == InvitationStatus.revoked;
case InvitationFilter.expired:
return inv.isExpired && inv.isPending;
case InvitationFilter.archived:
return inv.isArchived;
case InvitationFilter.all:
return true;
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormActions extends StatelessWidget {
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final bool canCreate;
final bool hasRoles;
final VoidCallback onSubmit;
const InvitationFormActions({
super.key,
required this.expiryDays,
required this.onExpiryChanged,
required this.canCreate,
required this.hasRoles,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Row(
children: [
Text(loc.invitationExpiresIn(expiryDays)),
Expanded(
child: Slider(
label: '$expiryDays',
min: 1,
max: 30,
value: expiryDays.toDouble(),
onChanged: (value) => onExpiryChanged(value.round()),
),
),
FilledButton.icon(
onPressed: canCreate && hasRoles ? onSubmit : null,
icon: const Icon(Icons.send_outlined),
label: Text(loc.invitationSendButton),
),
],
);
}
}

View File

@@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/permissions/descriptions/role.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormFields extends StatelessWidget {
final List<RoleDescription> roles;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
const InvitationFormFields({
super.key,
required this.roles,
required this.emailController,
required this.nameController,
required this.messageController,
required this.selectedRoleRef,
required this.onRoleChanged,
});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return Column(
children: [
Wrap(
spacing: 12,
runSpacing: 12,
children: [
SizedBox(
width: 320,
child: TextFormField(
controller: emailController,
decoration: InputDecoration(
labelText: loc.invitationEmailLabel,
prefixIcon: const Icon(Icons.alternate_email_outlined),
),
keyboardType: TextInputType.emailAddress,
validator: (value) => (value == null || value.trim().isEmpty)
? loc.errorEmailMissing
: null,
),
),
SizedBox(
width: 280,
child: TextFormField(
controller: nameController,
decoration: InputDecoration(
labelText: loc.invitationNameLabel,
prefixIcon: const Icon(Icons.person_outline),
),
),
),
SizedBox(
width: 260,
child: DropdownButtonFormField<String>(
initialValue: selectedRoleRef ?? (roles.isNotEmpty ? roles.first.storable.id : null),
items: roles.map((role) => DropdownMenuItem(
value: role.storable.id,
child: Text(role.describable.name),
)).toList(),
onChanged: roles.isEmpty ? null : onRoleChanged,
decoration: InputDecoration(
labelText: loc.invitationRoleLabel,
prefixIcon: const Icon(Icons.security_outlined),
),
),
),
],
),
const SizedBox(height: 12),
TextFormField(
controller: messageController,
minLines: 2,
maxLines: 3,
decoration: InputDecoration(
labelText: loc.invitationMessageLabel,
prefixIcon: const Icon(Icons.chat_bubble_outline),
),
),
],
);
}
}

View File

@@ -0,0 +1,45 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/invitations/widgets/form/view.dart';
class InvitationsForm extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
final bool canCreate;
final VoidCallback onSubmit;
const InvitationsForm({
super.key,
required this.formKey,
required this.emailController,
required this.nameController,
required this.messageController,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,
required this.onRoleChanged,
required this.canCreate,
required this.onSubmit,
});
@override
Widget build(BuildContext context) => InvitationFormView(
formKey: formKey,
emailController: emailController,
nameController: nameController,
messageController: messageController,
expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged,
selectedRoleRef: selectedRoleRef,
onRoleChanged: onRoleChanged,
canCreate: canCreate,
onSubmit: onSubmit,
);
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/provider/permissions.dart';
import 'package:pweb/pages/invitations/widgets/form/actions.dart';
import 'package:pweb/pages/invitations/widgets/form/fields.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationFormView extends StatelessWidget {
final GlobalKey<FormState> formKey;
final TextEditingController emailController;
final TextEditingController nameController;
final TextEditingController messageController;
final int expiryDays;
final ValueChanged<int> onExpiryChanged;
final String? selectedRoleRef;
final ValueChanged<String?> onRoleChanged;
final bool canCreate;
final VoidCallback onSubmit;
const InvitationFormView({
super.key,
required this.formKey,
required this.emailController,
required this.nameController,
required this.messageController,
required this.expiryDays,
required this.onExpiryChanged,
required this.selectedRoleRef,
required this.onRoleChanged,
required this.canCreate,
required this.onSubmit,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context)!;
final roles = context.watch<PermissionsProvider>().roleDescriptions;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withAlpha(40),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.invitationCreateTitle,
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
InvitationFormFields(
roles: roles,
emailController: emailController,
nameController: nameController,
messageController: messageController,
selectedRoleRef: selectedRoleRef,
onRoleChanged: onRoleChanged,
),
const SizedBox(height: 12),
InvitationFormActions(
expiryDays: expiryDays,
onExpiryChanged: onExpiryChanged,
canCreate: canCreate,
hasRoles: roles.isNotEmpty,
onSubmit: onSubmit,
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationsHeader extends StatelessWidget {
final AppLocalizations loc;
const InvitationsHeader({super.key, required this.loc});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
loc.invitationsTitle,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
loc.invitationsSubtitle,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Theme.of(context).hintColor),
),
],
);
}

View File

@@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pweb/pages/invitations/widgets/card/card.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationListBody extends StatelessWidget {
final List<Invitation> invitations;
const InvitationListBody({super.key, required this.invitations});
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
if (invitations.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Text(loc.invitationListEmpty),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: invitations.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (_, index) => InvitationsCard(invitation: invitations[index]),
);
}
}

View File

@@ -0,0 +1,16 @@
import 'package:flutter/material.dart';
import 'package:pweb/pages/invitations/widgets/list/view.dart';
class InvitationsList extends StatefulWidget {
const InvitationsList({super.key});
@override
State<InvitationsList> createState() => _InvitationsListState();
}
class _InvitationsListState extends State<InvitationsList> {
@override
Widget build(BuildContext context) => const InvitationListView();
}

View File

@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pshared/models/invitation/invitation.dart';
import 'package:pshared/provider/invitations.dart';
import 'package:pweb/models/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/invitation_filter.dart';
import 'package:pweb/pages/invitations/widgets/filter/chips.dart';
import 'package:pweb/pages/invitations/widgets/list/body.dart';
import 'package:pweb/pages/invitations/widgets/search_field.dart';
import 'package:pweb/widgets/error/snackbar.dart';
import 'package:pweb/generated/i18n/app_localizations.dart';
class InvitationListView extends StatefulWidget {
const InvitationListView({super.key});
@override
State<InvitationListView> createState() => _InvitationListViewState();
}
class _InvitationListViewState extends State<InvitationListView> {
final TextEditingController _searchController = TextEditingController();
InvitationFilter _filter = InvitationFilter.all;
String _query = '';
Object? _lastError;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _setQuery(String query) {
setState(() => _query = query.trim().toLowerCase());
}
void _setFilter(InvitationFilter filter) {
setState(() => _filter = filter);
}
void _notifyError(BuildContext context, Object error, AppLocalizations loc) {
if (identical(error, _lastError)) {
return;
}
_lastError = error;
postNotifyUserOfErrorX(
context: context,
errorSituation: loc.errorLoadingInvitations,
exception: error,
);
}
List<Invitation> _filteredInvitations(List<Invitation> invitations) {
final showArchived = _filter == InvitationFilter.archived;
Iterable<Invitation> filtered = invitations
.where((inv) => showArchived ? inv.isArchived : !inv.isArchived)
.where((inv) => invitationFilterMatches(_filter, inv));
if (_query.isNotEmpty) {
filtered = filtered.where((inv) {
return inv.inviteeDisplayName.toLowerCase().contains(_query)
|| inv.content.email.toLowerCase().contains(_query);
});
}
final sorted = filtered.toList()
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
return sorted;
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
final provider = context.watch<InvitationsProvider>();
if (provider.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (provider.error != null) {
_notifyError(context, provider.error!, loc);
} else {
_lastError = null;
}
final invitations = _filteredInvitations(provider.invitations);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InvitationSearchField(
controller: _searchController,
hintText: loc.invitationSearchHint,
onChanged: _setQuery,
),
const SizedBox(height: 12),
InvitationFilterChips(
selectedFilter: _filter,
onSelected: _setFilter,
),
const SizedBox(height: 16),
InvitationListBody(invitations: invitations),
],
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class InvitationSearchField extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
const InvitationSearchField({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
decoration: InputDecoration(
prefixIcon: const Icon(Icons.search),
hintText: hintText,
),
onChanged: onChanged,
);
}
}

View File

@@ -7,6 +7,7 @@ enum PayoutDestination {
dashboard(Icons.dashboard_outlined, 'dashboard'),
sendPayout(Icons.send_outlined, 'sendPayout'),
recipients(Icons.people_outline, 'recipients'),
invitations(Icons.mark_email_read_outlined, 'invitations'),
reports(Icons.insert_chart, 'reports'),
settings(Icons.settings_outlined, 'settings'),
methods(Icons.credit_card, 'methods'),
@@ -29,13 +30,15 @@ enum PayoutDestination {
case PayoutDestination.sendPayout:
return loc.payoutNavSendPayout;
case PayoutDestination.recipients:
return loc.payoutNavRecipients;
return loc.payoutNavRecipients;
case PayoutDestination.reports:
return loc.payoutNavReports;
case PayoutDestination.settings:
return loc.payoutNavSettings;
case PayoutDestination.methods:
return loc.payoutNavMethods;
case PayoutDestination.invitations:
return loc.payoutNavInvitations;
case PayoutDestination.payment:
return loc.payout;
case PayoutDestination.addrecipient:

View File

@@ -41,6 +41,7 @@ class PageSelector extends StatelessWidget {
PayoutDestination.methods,
PayoutDestination.editwallet,
PayoutDestination.walletTopUp,
PayoutDestination.invitations,
}
: PayoutDestination.values.toSet();
@@ -103,6 +104,9 @@ class PageSelector extends StatelessWidget {
if (location.startsWith(PayoutRoutes.recipientsPath)) {
return PayoutDestination.recipients;
}
if (location.startsWith(PayoutRoutes.invitationsPath)) {
return PayoutDestination.invitations;
}
if (location.startsWith(PayoutRoutes.settingsPath)) {
return PayoutDestination.settings;
}

View File

@@ -44,6 +44,7 @@ class PayoutSidebar extends StatelessWidget {
<PayoutDestination>[
PayoutDestination.dashboard,
PayoutDestination.recipients,
PayoutDestination.invitations,
PayoutDestination.methods,
PayoutDestination.reports,
];