Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
05163fd
index page HTML
simonredfern Mar 5, 2026
dffa9a1
Add pagination to my consents
simonredfern Mar 6, 2026
c058362
Allow Application Access and Scope
simonredfern Mar 6, 2026
d27ec88
Adding isNaturalPerson (defaults true) and principalUserId (defaults
simonredfern Mar 7, 2026
79e253f
Adding user_id and on_behalf_of_user_id to Transaction Request
simonredfern Mar 7, 2026
e914da8
Cleanup: Removed unused createTransactionRequestImpl
simonredfern Mar 7, 2026
e1e1848
Adding ENFORCE_TRANSACTION_REQUEST_MAKER_CHECKER to possible view
simonredfern Mar 7, 2026
68b0d6e
Adding checkMakerCheckerForTransactionRequest
simonredfern Mar 7, 2026
9587acd
Cleaning up: Moving some Agent generated MD files
simonredfern Mar 7, 2026
5e8656f
Adding canGetAnyBankLevelDynamicEntities
simonredfern Mar 7, 2026
c74980e
canGetAnyBankLevelDynamicEntities so we don't need to know bank_id in
simonredfern Mar 7, 2026
7b18ac6
CanCreateAnyBankLevelDynamicEntity + Maker Checking test
simonredfern Mar 7, 2026
1b280d2
return consumer_id on too many requests
simonredfern Mar 7, 2026
7f0add6
Fixtests in v6.0.0 System Level Dynamic Entity endpoints with snake_case
simonredfern Mar 8, 2026
72f9e4b
Update run_specific_tests.sh
simonredfern Mar 8, 2026
8b3685f
Using can_bypass_maker_checker_separation and adding
simonredfern Mar 8, 2026
4baeeb0
Create CLAUDE.md
simonredfern Mar 8, 2026
2c1486b
Update RestConnector_vMar2019_frozen_meta_data
simonredfern Mar 8, 2026
2368cb4
docfix: Info about the metadata view. + getCustomViews v6.0.0 uses
simonredfern Mar 8, 2026
154c87f
ViewJsonV600 add bank_id, account_id
simonredfern Mar 8, 2026
a33d35e
Mandates
simonredfern Mar 8, 2026
5391ed5
test/fixed failed tests
hongwei1 Mar 8, 2026
0f5d491
test/fixed failed tests RestConnector_vMar2019_FrozenTest
hongwei1 Mar 8, 2026
d47777d
Merge remote-tracking branch 'Simon/develop' into develop-Simon
hongwei1 Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Project Instructions

## Working Style
- Never blame pre-existing issues or other commits. No excuses, no finger-pointing — diagnose and resolve.
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,9 @@ object ToSchemify {
MappedRegulatedEntity,
AtmAttribute,
AbacRule,
code.mandate.Mandate,
code.mandate.MandateProvision,
code.mandate.SignatoryPanel,
MappedBank,
MappedBankAccount,
BankAccountRouting,
Expand Down
20 changes: 16 additions & 4 deletions obp-api/src/main/scala/code/api/constant/constant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,8 @@ object Constant extends MdcLoggable {
final val CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY = "can_add_transaction_request_to_beneficiary"
final val CAN_GRANT_ACCESS_TO_VIEWS = "can_grant_access_to_views"
final val CAN_REVOKE_ACCESS_TO_VIEWS = "can_revoke_access_to_views"
final val CAN_BYPASS_MAKER_CHECKER_SEPARATION = "can_bypass_maker_checker_separation"
final val CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE = "can_answer_transaction_request_challenge"

final val SYSTEM_OWNER_VIEW_PERMISSION_ADMIN = List(
CAN_SEE_AVAILABLE_VIEWS_FOR_BANK_ACCOUNT,
Expand All @@ -391,7 +393,9 @@ object Constant extends MdcLoggable {
CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT,
CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY,
CAN_GRANT_ACCESS_TO_VIEWS,
CAN_REVOKE_ACCESS_TO_VIEWS
CAN_REVOKE_ACCESS_TO_VIEWS,
CAN_BYPASS_MAKER_CHECKER_SEPARATION,
CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE
)

final val SYSTEM_MANAGER_VIEW_PERMISSION = List(
Expand All @@ -400,12 +404,16 @@ object Constant extends MdcLoggable {
CAN_CREATE_CUSTOM_VIEW,
CAN_DELETE_CUSTOM_VIEW,
CAN_UPDATE_CUSTOM_VIEW,
CAN_GET_CUSTOM_VIEW
CAN_GET_CUSTOM_VIEW,
CAN_BYPASS_MAKER_CHECKER_SEPARATION,
CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE
)

final val SYSTEM_INITIATE_PAYMENTS_BERLIN_GROUP_PERMISSION = List(
CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT,
CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY
CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY,
CAN_BYPASS_MAKER_CHECKER_SEPARATION,
CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE
)

final val SYSTEM_PUBLIC_VIEW_PERMISSION = List(
Expand Down Expand Up @@ -563,7 +571,9 @@ object Constant extends MdcLoggable {
CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME,
CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS,
CAN_SEE_TRANSACTION_STATUS,
CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT
CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT,
CAN_BYPASS_MAKER_CHECKER_SEPARATION,
CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE
)

final val ALL_VIEW_PERMISSION_NAMES = List(
Expand Down Expand Up @@ -660,6 +670,8 @@ object Constant extends MdcLoggable {
CAN_ADD_TRANSACTION_REQUEST_TO_BENEFICIARY,
CAN_GRANT_ACCESS_TO_VIEWS,
CAN_REVOKE_ACCESS_TO_VIEWS,
CAN_BYPASS_MAKER_CHECKER_SEPARATION,
CAN_ANSWER_TRANSACTION_REQUEST_CHALLENGE,
)


Expand Down
36 changes: 36 additions & 0 deletions obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -774,6 +774,36 @@ object ApiRole extends MdcLoggable{
case class CanExecuteAbacRule(requiresBankId: Boolean = false) extends ApiRole
lazy val canExecuteAbacRule = CanExecuteAbacRule()

// Mandate roles (bank-level)
case class CanCreateMandate(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateMandate = CanCreateMandate()
case class CanGetMandate(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetMandate = CanGetMandate()
case class CanUpdateMandate(requiresBankId: Boolean = true) extends ApiRole
lazy val canUpdateMandate = CanUpdateMandate()
case class CanDeleteMandate(requiresBankId: Boolean = true) extends ApiRole
lazy val canDeleteMandate = CanDeleteMandate()

// Mandate Provision roles (bank-level)
case class CanCreateMandateProvision(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateMandateProvision = CanCreateMandateProvision()
case class CanGetMandateProvision(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetMandateProvision = CanGetMandateProvision()
case class CanUpdateMandateProvision(requiresBankId: Boolean = true) extends ApiRole
lazy val canUpdateMandateProvision = CanUpdateMandateProvision()
case class CanDeleteMandateProvision(requiresBankId: Boolean = true) extends ApiRole
lazy val canDeleteMandateProvision = CanDeleteMandateProvision()

// Signatory Panel roles (bank-level)
case class CanCreateSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateSignatoryPanel = CanCreateSignatoryPanel()
case class CanGetSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetSignatoryPanel = CanGetSignatoryPanel()
case class CanUpdateSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole
lazy val canUpdateSignatoryPanel = CanUpdateSignatoryPanel()
case class CanDeleteSignatoryPanel(requiresBankId: Boolean = true) extends ApiRole
lazy val canDeleteSignatoryPanel = CanDeleteSignatoryPanel()

case class CanGetSystemLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetSystemLevelDynamicEntities = CanGetSystemLevelDynamicEntities()

Expand All @@ -783,6 +813,9 @@ object ApiRole extends MdcLoggable{
case class CanCreateBankLevelDynamicEntity(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateBankLevelDynamicEntity = CanCreateBankLevelDynamicEntity()

case class CanCreateAnyBankLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole
lazy val canCreateAnyBankLevelDynamicEntity = CanCreateAnyBankLevelDynamicEntity()

case class CanUpdateSystemLevelDynamicEntity(requiresBankId: Boolean = false) extends ApiRole
lazy val canUpdateSystemDynamicEntity = CanUpdateSystemLevelDynamicEntity()

Expand All @@ -807,6 +840,9 @@ object ApiRole extends MdcLoggable{
case class CanGetBankLevelDynamicEntities(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetBankLevelDynamicEntities = CanGetBankLevelDynamicEntities()

case class CanGetAnyBankLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetAnyBankLevelDynamicEntities = CanGetAnyBankLevelDynamicEntities()

case class CanGetDynamicEntityDiagnostics(requiresBankId: Boolean = false) extends ApiRole
lazy val canGetDynamicEntityDiagnostics = CanGetDynamicEntityDiagnostics()

Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/util/ApiTag.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ object ApiTag {
val apiTagEntitlement = ResourceDocTag("Entitlement")
val apiTagRole = ResourceDocTag("Role")
val apiTagABAC = ResourceDocTag("ABAC")
val apiTagMandate = ResourceDocTag("Mandate")
val apiTagScope = ResourceDocTag("Scope")
val apiTagOwnerRequired = ResourceDocTag("OwnerViewRequired")
val apiTagCounterparty = ResourceDocTag("Counterparty")
Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@ object ErrorMessages {
val AccountAccessRequestCannotBeCreated = "OBP-30277: Account Access Request could not be created."
val AccountAccessRequestStatusNotInitiated = "OBP-30278: Account Access Request status is not INITIATED. Only INITIATED requests can be approved or rejected."
val MakerCheckerSameUser = "OBP-30279: The checker (approver/rejecter) cannot be the same user as the maker (requestor). Maker/Checker separation is required."
val MakerCheckerUnknownMaker = "OBP-30283: Maker/Checker separation is required for this view but the Transaction Request has no user_id (maker) recorded. Cannot verify separation."
val BusinessJustificationRequired = "OBP-30280: Business justification is required."
val CheckerCommentRequiredForRejection = "OBP-30281: A comment is required when rejecting an Account Access Request."
val AccountAccessRequestCannotBeUpdated = "OBP-30282: Account Access Request could not be updated."
Expand Down
73 changes: 71 additions & 2 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2956,7 +2956,7 @@ object Glossary extends MdcLoggable {
|Berlin Group consents with status "received" that remain unfinished (e.g. the PSU never completed the SCA flow) beyond a configured time threshold are automatically rejected.
|
|* `berlin_group_outdated_consents_time_in_seconds` - Time in seconds after which an unfinished consent is considered outdated. Default: **300** (5 minutes).
|* `berlin_group_outdated_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled).
|* `berlin_group_outdated_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated consents. Default: **599**. Set to 0 to disable.
|
|Example:
|
Expand All @@ -2967,13 +2967,16 @@ object Glossary extends MdcLoggable {
|## Expired Consents
|
|Berlin Group consents with status "valid" whose `validUntil` date has passed are automatically transitioned to "expired" status.
|OBP consents with status "ACCEPTED" whose `validUntil` date has passed are automatically transitioned to "EXPIRED" status.
|
|* `berlin_group_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled).
|* `berlin_group_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired Berlin Group consents. Default: **597**. Set to 0 to disable.
|* `obp_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired OBP consents. Default: **595**. Set to 0 to disable.
|
|Example:
|
| # Check for expired consents every 120 seconds
| berlin_group_expired_consents_interval_in_seconds = 120
| obp_expired_consents_interval_in_seconds = 120
|
""")

Expand Down Expand Up @@ -5251,6 +5254,72 @@ object Glossary extends MdcLoggable {
|
""")

glossaryItems += GlossaryItem(
title = "Mandates",
description =
s"""
|# Mandates
|
|## Overview
|
|A Mandate is a formal agreement between a corporate customer and a bank that defines who can operate an account, what they can do, and under what conditions.
|
|In OBP, a Mandate is an entity that ties together existing authorisation constructs (Views, ABAC Rules, Challenges) into a single, auditable policy document.
|
|## Structure
|
|A Mandate has three parts:
|
|### 1. Mandate
|
|The top-level container. It is linked to a bank account and a corporate customer, and holds the legal text, status (ACTIVE, SUSPENDED, EXPIRED, DRAFT), and validity period.
|
|### 2. Mandate Provisions
|
|Each provision maps a clause of the mandate to an OBP enforcement mechanism. Provision types:
|
|- **SIGNATORY_RULE** — defines who can sign and in what combination (e.g., "2 from Panel A" or "1 from Panel A and 1 from Panel B")
|- **VIEW_ASSIGNMENT** — links a Signatory Panel to a View, controlling what members of that panel can see and do
|- **ABAC_CONDITION** — links to an ABAC rule for attribute-based conditions (e.g., department matching, amount limits)
|- **RESTRICTION** — a negative rule that blocks certain operations (e.g., no international payments)
|- **NOTIFICATION** — triggers a notification rather than blocking (e.g., alert CFO for payments over a threshold)
|
|Provisions can specify conditions (e.g., amount thresholds, currency), link to a View, an ABAC Rule, and/or a Challenge type.
|
|### 3. Signatory Panels
|
|A Signatory Panel is a named set of users who are authorised to act under the mandate. For example:
|
|- Panel A: Directors (user-1, user-2, user-3)
|- Panel B: Finance team (user-4, user-5)
|
|Provisions reference panels by ID and specify how many signatories are required from each panel.
|
|## How it connects to existing OBP features
|
|- **Views** control what each panel member can see and do on the account (e.g., canSeeTransactionAmount, canAddTransactionRequestToBeneficiary)
|- **ABAC Rules** provide attribute-based conditions evaluated at runtime (e.g., user department must match account business unit)
|- **Challenges / Maker-Checker** enforce signatory requirements. A provision can require multiple challenges answered by different users from specified panels
|- **Corporate Customers** (CORPORATE / SUBSIDIARY types with parent-child hierarchy) represent the legal entities that mandates apply to
|
|## Example
|
|ACME Corp has a mandate on their operating account:
|
|1. Panel A (Directors): user-1, user-2, user-3
|2. Panel B (Finance): user-4, user-5
|3. Provision: payments < 5,000 EUR require 1 signature from Panel A
|4. Provision: payments 5,000-50,000 EUR require 2 signatures from Panel A
|5. Provision: payments > 50,000 EUR require 1 from Panel A and 1 from Panel B
|
|## API Endpoints
|
|Mandates, Provisions, and Signatory Panels each have CRUD endpoints under the Mandate tag.
|
|All endpoints require bank-level roles (e.g., CanCreateMandate, CanGetMandateProvision, CanUpdateSignatoryPanel).
|
""")

///////////////////////////////////////////////////////////////////
// NOTE! Some glossary items are generated in ExampleValue.scala
//////////////////////////////////////////////////////////////////
Expand Down
96 changes: 95 additions & 1 deletion obp-api/src/main/scala/code/api/util/NewStyle.scala
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@
import code.util.Helper
import code.util.Helper.MdcLoggable
import code.validation.{JsonSchemaValidationProvider, JsonValidation}
import code.transactionChallenge.Challenges
import code.transactionrequests.TransactionRequests
import code.views.Views
import code.views.system.ViewPermission
import code.webhook.AccountWebhook
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.dto.{CustomerAndAttribute, GetProductsParam, ProductCollectionItemsTree}
Expand Down Expand Up @@ -546,6 +549,73 @@
}
}

def checkMakerCheckerForTransactionRequest(
bankId: BankId,
accountId: AccountId,
viewId: ViewId,
transactionRequestId: TransactionRequestId,
challengeId: String,
checkerUserId: String,
callContext: Option[CallContext]
): Future[Boolean] = {
Future {
// Check if the view has the can_bypass_maker_checker_separation permission
val permissionName = Constant.CAN_BYPASS_MAKER_CHECKER_SEPARATION
val hasPermission = Views.views.vend.customView(viewId, BankIdAccountId(bankId, accountId)) match {
case Full(view) => ViewPermission.findViewPermission(view, permissionName).isDefined
case _ => Views.views.vend.systemView(viewId) match {
case Full(view) => ViewPermission.findViewPermission(view, permissionName).isDefined
case _ => false
}
}

// If the view has can_bypass_maker_checker_separation, no check needed
if (hasPermission) {
Full(true)
} else {
// Maker-checker is required — get the TR
val transactionRequest = TransactionRequests.transactionRequestProvider.vend
.getTransactionRequest(transactionRequestId)

transactionRequest match {
case Full(tr) =>
tr.user_id match {
case None | Some("") =>
Failure(MakerCheckerUnknownMaker)
case Some(makerUserId) if makerUserId == checkerUserId =>
// Same user as maker — check if this is a multi-challenge scenario
// where the user is answering their own assigned SCA challenge
val challenges = Challenges.ChallengeProvider.vend
.getChallengesByTransactionRequestId(transactionRequestId.value)
challenges match {
case Full(challengeList) if challengeList.size > 1 =>
// Multiple challenges: allow if this specific challenge is assigned to the checker
val isOwnChallenge = challengeList.exists(c =>
c.challengeId == challengeId && c.expectedUserId == checkerUserId
)
if (isOwnChallenge) Full(true)
else Failure(MakerCheckerSameUser)
case _ =>
// Single challenge or no challenges found: block same user
Failure(MakerCheckerSameUser)
}
case _ =>
tr.on_behalf_of_user_id match {
case Some(onBehalfOfUserId) if onBehalfOfUserId.nonEmpty && onBehalfOfUserId == checkerUserId =>
Failure(MakerCheckerSameUser)
case _ =>
Full(true)
}
}
case _ =>
Failure(s"$InvalidTransactionRequestId Current TransactionRequestId($transactionRequestId)")
}
}
} map {
unboxFullOrFail(_, callContext, "Maker/Checker check failed")
}
}

def getConsumerByConsumerId(consumerId: String, callContext: Option[CallContext]): Future[Consumer] = {
Consumers.consumers.vend.getConsumerByConsumerIdFuture(consumerId) map {
unboxFullOrFail(_, callContext, s"$ConsumerNotFoundByConsumerId Current ConsumerId is $consumerId", 404)
Expand Down Expand Up @@ -1269,15 +1339,39 @@
}
}

def validateChallengeAnswer(challengeId: String, suppliedAnswer: String, suppliedAnswerType:SuppliedAnswerType.Value, callContext: Option[CallContext]): OBPReturnType[Boolean] =
def validateChallengeAnswer(challengeId: String, suppliedAnswer: String, suppliedAnswerType:SuppliedAnswerType.Value, callContext: Option[CallContext]): OBPReturnType[Boolean] =
Connector.connector.vend.validateChallengeAnswerV2(challengeId, suppliedAnswer, suppliedAnswerType, callContext) map { i =>
(unboxFullOrFail(i._1, callContext, s"${
InvalidChallengeAnswer
.replace("answer may be expired.", s"answer may be expired (${transactionRequestChallengeTtl} seconds).")

Check failure on line 1346 in obp-api/src/main/scala/code/api/util/NewStyle.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "answer may be expired." 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZzPwxxDbUJJ94dzLWJY&open=AZzPwxxDbUJJ94dzLWJY&pullRequest=2725
.replace("up your allowed attempts.", s"up your allowed attempts (${allowedAnswerTransactionRequestChallengeAttempts} times).")

Check failure on line 1347 in obp-api/src/main/scala/code/api/util/NewStyle.scala

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "up your allowed attempts." 3 times.

See more on https://sonarcloud.io/project/issues?id=OpenBankProject_OBP-API&issues=AZzPwxxDbUJJ94dzLWJX&open=AZzPwxxDbUJJ94dzLWJX&pullRequest=2725
}"), i._2)
}

/**
* Validate a challenge answer without checking the userId.
* Used when a checker (different user) answers a challenge that was assigned to the maker,
* in the single-challenge maker-checker scenario. The maker-checker check has already
* verified this user is allowed to answer.
*/
def validateChallengeAnswerWithoutUserIdCheck(
challengeId: String,
suppliedAnswer: String,
suppliedAnswerType: SuppliedAnswerType.Value,
callContext: Option[CallContext]
): OBPReturnType[Boolean] = {
Future {
val result = Challenges.ChallengeProvider.vend.validateChallenge(challengeId, suppliedAnswer, None)
(Full(result.isDefined), callContext)
} map { i =>
(unboxFullOrFail(i._1, callContext, s"${
InvalidChallengeAnswer
.replace("answer may be expired.", s"answer may be expired (${transactionRequestChallengeTtl} seconds).")
.replace("up your allowed attempts.", s"up your allowed attempts (${allowedAnswerTransactionRequestChallengeAttempts} times).")
}"), i._2)
}
}

def allChallengesSuccessfullyAnswered(
bankId: BankId,
accountId: AccountId,
Expand Down
Loading