diff --git a/README.md b/README.md index 133b4f9..17eff73 100644 --- a/README.md +++ b/README.md @@ -3,176 +3,22 @@ # Instructions to use the application 1. Register an organization with Asgardeo. -2. Create [custom attributes](https://wso2.com/asgardeo/docs/guides/users/attributes/manage-attributes/) named `accountType` and `businessName`. Add the accountType and country attributes to the profile scope. +2. Create [custom attributes](https://wso2.com/asgardeo/docs/guides/users/attributes/manage-attributes/) named `accountType` and `businessName`. Add the businessName, accountType and country attributes to the profile scope. 3. Create another [custom attribute](https://wso2.com/asgardeo/docs/guides/users/attributes/manage-attributes/) with the name `isFirstLogin`. 4. Enable the [Attribute Update Verification](https://wso2.com/asgardeo/docs/guides/users/attributes/user-attribute-change-verification/) for user email. 5. Create a SPA application. - * Enable the `Code` and `Refresh Grant` types + * Navigate to the "Shared Access" tab and share the application with all organizations. + * Enable the `Code`, `Refresh Grant` and `Organization Switch` types. + * Note that the organization switch grant type is available only after shared access is enabled. * Add authorize redirect URL: `http://localhost:5173` and allowed origin: `http://localhost:5173` - * Add the `country` and `accountType` to Profile scope navigating to `User Attributes & Stores` -> `Attributes` -> `OpenId Connect` -> `Scopes` -> `Profile` -> `New Attribute`. + * Add the `mobile`, `country`, `email` and `accountType` to Profile scope navigating to `User Attributes & Stores` -> `Attributes` -> `OpenId Connect` -> `Scopes` -> `Profile` -> `New Attribute`. * Enable the following scopes and attributes within the client application created. - * `Profile - Country, First Name, Last Name, Username, Birth Date, AccountType; Email - email; Phone - telephone; Address - country.` + * `Profile - Country, First Name, Last Name, Username, Birth Date, AccountType, Business Name, Email; Email - email; Phone - telephone; Address - country.` 6. Enable the following authenticators within the client application: * `Identifier First` - First Step * `Username and Password`, `Passkey` - Second Step * `Totp` and `Email OTP` - Third Step -7. Configure the following conditional authentication script (Replace the `` with server URL): -```js -var moneyTransferThres = 10000; -var riskEndpoint = "/risk" - -var onLoginRequest = function(context) { - - var isMoneyTransfer = context.request.params.action && context.request.params.action[0] === "money-transfer"; - - if (isMoneyTransfer) { - Log.info("Custom param:" + context.request.params.action[0]); - Log.info("Custom param:" + context.request.params.transfer_amount[0]); - var amount = parseInt(context.request.params.transfer_amount[0] || -1); - - executeStep(1); - if (amount > moneyTransferThres) { - executeStep(4, { - stepOptions: { - forceAuth: 'true' - } - }, {}); - } - - } else { - - executeStep(1, { - onSuccess: function(context) { - var user = context.steps[1].subject; - var accountType = user.localClaims["http://wso2.org/claims/accountType"]; - var country = user.localClaims["http://wso2.org/claims/country"]; - Log.info("Account Type: " + accountType); - Log.info("Country: " + country); - var ipAddress = context.request.ip; - Log.info("IP Address: " + ipAddress); - var requestPayload = { - ip: ipAddress, - country: country, - }; - if (accountType === "Personal") { - httpPost(riskEndpoint, requestPayload, { - "Accept": "application/json" - }, { - onSuccess: function(context, data) { - Log.info("Successfully invoked the external API."); - Log.info("Logging data for country risk: " + data.hasRisk); - - if (data.hasRisk === false) { - executeStep(2, { - authenticationOptions: [{ - authenticator: 'FIDOAuthenticator' - }, { - authenticator: 'BasicAuthenticator' - }] - }, { - onSuccess: function(context) { - var user = context.currentKnownSubject; - var sessions = getUserSessions(user); - Log.info(sessions); - if (sessions.length > 0) { - executeStep(3, { - authenticationOptions: [{ - authenticator: 'email-otp-authenticator' - }] - }, {}); - } - } - }); - } else { - executeStep(2, { - authenticationOptions: [{ - authenticator: 'FIDOAuthenticator' - }, { - authenticator: 'BasicAuthenticator' - }], - }, {}); - Log.info("In 2nd step for Personal Accounts"); - - executeStep(3, { - authenticationOptions: [{ - authenticator: 'email-otp-authenticator' - }] - }, {}); - } - }, - onFail: function(context, data) { - Log.error("Failed to invoke risk API"); - fail(); - } - }); - } else if (accountType === "Business") { - Log.info("In second step for Business"); - - executeStep(2, { - authenticationOptions: [{ - authenticator: 'BasicAuthenticator' - }] - }, {}); - var preferredClaimURI = "http://wso2.org/claims/identity/preferredMFAOption"; - var preferredClaim = user.localClaims[preferredClaimURI]; - - if (preferredClaim != null) { - Log.info("Preferred Claim Available"); - - var jsonObj = JSON.parse(preferredClaim); - var authenticationOption = jsonObj.authenticationOption; - Log.info("preferredClaim authenticationOption " + authenticationOption); - executeStep(3, { - authenticationOptions: [{ - authenticator: authenticationOption - }], - }, {}); - } else { - Log.info("Preferred claim not available and in 3rd step"); - executeStep(3, { - authenticationOptions: [{ - authenticator: 'totp' - }, { - authenticator: 'email-otp-authenticator' - }] - }, { - onSuccess: function(context) { - var preferredClaimURI = "http://wso2.org/claims/identity/preferredMFAOption"; - Log.info("3rd step successful"); - var user = context.steps[3].subject; - var isFirstLogin = user.localClaims["http://wso2.org/claims/isFirstLogin"]; - Log.info("User isFirstLogin claim:" + isFirstLogin); - if (isFirstLogin === "false") { - var authenticatorName = context.steps[3].authenticator; - var preferredMFA = { - authenticationOption: authenticatorName - }; - user.localClaims[preferredClaimURI] = JSON.stringify(preferredMFA); - Log.info("Preferred MFA set from second login for user" + user.username + " as " + user.localClaims[preferredClaimURI]); - } else { - user.localClaims["http://wso2.org/claims/isFirstLogin"] = false; - Log.info("User logged in for the first time. Setting isFirstLogin to false"); - } - } - }); - } - } - }, - onFail: function(context) { - Log.info('User not found'); - var parameterMap = { - 'errorCode': 'login_failed', - 'errorMessage': 'login could not be completed', - "errorURI": 'https://localhost:9443/authenticationendpoint/login.jsp' - }; - fail(parameterMap); - - } - }); - } -}; - -``` +7. Configure the conditional authentication script (Replace the `` with server URL) with the one found at conditional-auth-script.js. 8. Create a standard web application. 9. Navigate to the "Shared Access" tab and share the application with all organizations. 10. Enable the following grant types: @@ -191,6 +37,10 @@ redirect url: `https://localhost:5003`, allowed origin: `https://localhost:5003 ``` internal_organization_create internal_organization_view internal_organization_update internal_organization_delete ``` + - OAuth2 Introspection API + ``` + internal_oauth2_introspect + ``` - Organization APIs: - SCIM2 Users API with the scopes: ``` @@ -201,7 +51,7 @@ redirect url: `https://localhost:5003`, allowed origin: `https://localhost:5003 internal_org_user_mgt_view internal_org_role_mgt_delete internal_org_role_mgt_create internal_org_role_mgt_update internal_org_role_mgt_view ``` -13. Navigate to the Roles tab and create an application role named `Business Administrator` with the permissions for the SCIM2 Users and SCIM2 Roles organization APIs. +13. Navigate to the Roles tab and create an application role named `Business Administrator` with the permissions for the SCIM2 Users and SCIM2 Roles organization APIs. Also, create roles `Manager`, `Auditor` and `Member`. 14. Navigate to Connections -> Passkey Setup -> Add the Trusted Origins: `http://localhost:5173` and enable `Allow Passkey usernameless authentication` option. 15. Configure [Onfido identity verification](https://wso2.com/asgardeo/docs/guides/identity-verification/add-identity-verification-with-onfido/) for your organization. diff --git a/app/global.d.ts b/app/global.d.ts index bab0d1c..e5ee48d 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -20,6 +20,7 @@ interface Window { APP_BASE_URL: string; ASGARDEO_BASE_URL: string; APP_CLIENT_ID: string; + APP_NAME: string; DISABLED_FEATURES: string[]; TRANSFER_THRESHOLD: number; IDENTITY_VERIFICATION_PROVIDER_ID: string; diff --git a/app/package.json b/app/package.json index 9ce4b70..15f7992 100644 --- a/app/package.json +++ b/app/package.json @@ -20,7 +20,9 @@ "@fortawesome/free-regular-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.2", + "@mui/icons-material": "^6.5.0", "@mui/material": "^6.4.5", + "@mui/x-data-grid": "^8.11.1", "@vitejs/plugin-react": "^4.3.4", "axios": "^1.8.1", "notistack": "^3.0.2", @@ -28,6 +30,7 @@ "prop-types": "^15.8.1", "qrcode.react": "^4.2.0", "react": "^19.0.0", + "react-bootstrap": "^2.10.10", "react-dom": "^19.0.0", "react-router": "^7.3.0", "vite": "^6.1.1", diff --git a/app/public/config.example.js b/app/public/config.example.js index a02fb9d..1eb84a6 100644 --- a/app/public/config.example.js +++ b/app/public/config.example.js @@ -5,6 +5,7 @@ window.config = { ASGARDEO_BASE_URL: "", ORGANIZATION_NAME: "", APP_CLIENT_ID: "", + APP_NAME: "", DISABLED_FEATURES: [], TRANSFER_THRESHOLD: 10000, IDENTITY_VERIFICATION_PROVIDER_ID: "", diff --git a/app/src/App.jsx b/app/src/App.jsx index bc02db4..782ef8a 100644 --- a/app/src/App.jsx +++ b/app/src/App.jsx @@ -16,7 +16,7 @@ * under the License. */ -import { useAsgardeo, SignedIn, SignOutButton, SignedOut, SignInButton } from "@asgardeo/react"; +import { useAsgardeo, SignedIn, SignOutButton, SignedOut, SignInButton, useUser } from "@asgardeo/react"; import { lazy, Suspense, useState } from "react"; import { BrowserRouter as Router, @@ -26,7 +26,7 @@ import { NavLink } from "react-router"; import { SnackbarProvider } from "notistack"; -import { ROUTES, SITE_SECTIONS } from "./constants/app-constants"; +import { ACCOUNT_TYPES, ROUTES, SITE_SECTIONS } from "./constants/app-constants"; import PersonalBankingPage from "./pages/personal-banking"; import RegisterAccountPage from "./pages/register-account"; import UserProfilePage from "./pages/user-profile"; @@ -37,10 +37,13 @@ import "./assets/css/style.scss"; import { BankAccountProvider } from "./context/bank-account-provider"; import IdentityVerificationPage from "./pages/identity-verification"; import { IdentityVerificationProvider } from "./context/identity-verification-provider"; +import { Dropdown, DropdownButton } from "react-bootstrap"; +import BusinessProfilePage from "./pages/business-profile"; const App = () => { - const { isSignedIn, signIn, signOut } = useAsgardeo(); + const { isSignedIn } = useAsgardeo(); const [ siteSection, setSiteSection ] = useState(""); + const { profile} = useUser(); const TransferFundsPage = lazy(() => import("./pages/transfer-funds")); const TransferFundsVerifyPage = lazy(() => import("./pages/transfer-funds-verify")); @@ -70,7 +73,7 @@ const App = () => { - Logout + Logout @@ -79,7 +82,23 @@ const App = () => { Open an account - Login + | + + + + Personal Login + + + + + Business Login + + + @@ -166,11 +185,20 @@ const App = () => { { isSignedIn && } /> } + { isSignedIn && + } /> + } {/* } /> */} + <> + {profile && profile["urn:scim:schemas:extension:custom:User"].accountType === ACCOUNT_TYPES.BUSINESS ? ( + + ) : ( + + )} + ) : ( ) diff --git a/app/src/api/profile.js b/app/src/api/profile.js index bd1a6e9..14a3698 100644 --- a/app/src/api/profile.js +++ b/app/src/api/profile.js @@ -25,6 +25,10 @@ export const closeAccount = (token) => { }) }; +export const closeBusinessAccount = (businessName) => { + return axiosClient.delete(`/close-business-account?businessName=${businessName}`); +}; + export const resetPassword = (username, currentPassword, newPassword) => { // In case the password contains non-ascii characters, converting to valid ascii format. const encoder = new TextEncoder(); diff --git a/app/src/assets/css/style.scss b/app/src/assets/css/style.scss index a5ce40a..173cd41 100644 --- a/app/src/assets/css/style.scss +++ b/app/src/assets/css/style.scss @@ -141,6 +141,22 @@ button { } .login_link, .login_link:hover, .login_link:focus { + color: $black !important; + background-color: $white !important; + outline: 1px !important; + border: none !important; + padding: 0px !important; + display: block; + border-radius: 0; + width: 100% !important; + + &:hover { + background-color: $primary2 !important; + color: $white !important; + } +} + +.logout_link { color: $white; background-color: $primary2; outline: 1px !important; @@ -151,6 +167,38 @@ button { border-radius: 0; } +#dropdown-custom-components { + text-transform: none !important; + border-radius: 0; + padding: 4px 20px !important; +} + +#dropdown-custom-components:focus, +#dropdown-custom-components:active, +#dropdown-custom-components:hover, +#dropdown-custom-components.show { + background-color: $primary2 !important; + box-shadow: none !important; + border-color: inherit !important; +} + +.dropdown-menu { + margin-top: 0px !important; + padding: 0px !important; + z-index: 100000 !important; + border-radius: 0 !important; + border-color: $primary2; +} + +.dropdown-item-custom { + padding: 0px; +} + +.dropdown-item-custom-top { + margin-bottom: 1px; + border-bottom: $black; +} + .login_details { margin-right: 20px; display: flex; @@ -218,6 +266,10 @@ button { } } +.tab-sub-heading { + font-size: 150%; +} + /*header section*/ .hero_area { height: 100vh; @@ -319,7 +371,7 @@ button { } .custom_nav-container { - z-index: 99999; + // z-index: 99999; padding: 0; min-height: 96px; @@ -815,10 +867,14 @@ a:focus { .contact_form-container { margin-top: 35px; - label { + label:not(.radio-label), .password-label { text-transform: uppercase; } + .radio-label { + margin-bottom: 0; + } + input, select { border: 1px solid color.scale($black, $lightness: 80%); outline: none; @@ -842,6 +898,12 @@ a:focus { } } + input[type="radio"], input[type="checkbox"] { + width: auto; + height: auto; + } + + .country-select { margin-bottom: 20px; @@ -915,6 +977,10 @@ a:focus { color: $black; } } + + input[type="radio"] { + margin: 0 8px 0 0; + } } } @@ -1139,6 +1205,16 @@ a:focus { } } +.box-zone { + margin-top: 50px; + border: 1px solid $black; + padding: 30px; + + h5 { + color: $black; + } +} + input[type="password"]:focus ~ .password-validation, input.password-field:focus ~ .password-validation { display: block; @@ -1249,6 +1325,10 @@ input.password-field:focus ~ .password-validation { } } +.manage-user-btn { + width: 50%; +} + /* end Dialog box */ /* Password Policy Indicator */ @@ -1604,3 +1684,93 @@ ul.password-policy-indicator { width: 600px; } } + +.user-data { + p { + margin: 0 0; + } +} + +.user-data-suffix { + display: flex; + align-items: "center"; +} + +.mfa-settings { + display: flex; + flex-direction: column; + gap: 10px; // space between the label and checkbox group + + .mfa-label { + font-weight: bold; + } + + .mfa-options { + display: flex; + flex-direction: column; // stack checkboxes vertically + gap: 8px; // space between each checkbox + + label { + display: flex; + align-items: center; + gap: 8px; // space between the checkbox and its text + } + } +} + +.country { + border: none; +} + + +.gold-button { + @include hero_btn($primary2, $white, 10px, 60px); + text-transform: uppercase; + font-weight: bold; + outline-color: $primary2; + width: 100%; + border-radius: 0; + + &.secondary { + background-color: $primary1; + outline-color: $primary1; + color: $white; + + &:hover { + background-color: transparent; + border-color: $primary1; + color: $primary1; + } + } +} + +.black-button { + @include hero_btn($primary2, $white, 10px, 60px); + text-transform: uppercase; + font-weight: bold; + background-color: $primary1 !important; + outline-color: $primary1; + color: $white; + width: 100%; + + &:hover { + background-color: transparent !important; + border-color: $primary1; + color: $primary1; + } +} + +.gold-button:disabled { + opacity: 0.6; + cursor: not-allowed; + background-color: #ccc; + color: #666; +} + +.MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: $primary2 !important; +} + +label.Mui-focused { + color: $primary2 !important; +} diff --git a/app/src/components/business-user-profile/add-user.jsx b/app/src/components/business-user-profile/add-user.jsx new file mode 100644 index 0000000..48304fe --- /dev/null +++ b/app/src/components/business-user-profile/add-user.jsx @@ -0,0 +1,264 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { useSnackbar } from "notistack"; +import CountrySelect from "../country-select"; +import { environmentConfig } from "../../util/environment-util"; +import { getPasswordPolicy } from "../../api/server-configurations"; +import PasswordField from "../common/password-field"; +import { useHttpSwitch } from "../../sdk/httpSwitch"; +import { useUser } from "@asgardeo/react"; + +const AddUser = ({ onCancel }) => { + const { enqueueSnackbar } = useSnackbar(); + const httpSwitch = useHttpSwitch(); + const [ passwordValidationRules, setPasswordValidationRules ] = useState({}); + const [ isNewPasswordValid, setIsNewPasswordValid ] = useState(false); + const [ passwordOption, setPasswordOption ] = useState("invite"); + const { profile } = useUser(); + const businessName = profile["urn:scim:schemas:extension:custom:User"].businessName; + + const [ formData, setFormData ] = useState({ + username: "", + givenName: "", + familyName: "", + dob: "", + email: "", + mobile: "", + country: "", + password: "" + }); + + const request = requestConfig => + httpSwitch.request(requestConfig) + .then(response => response) + .catch(error => error); + + useEffect(() => { + getPasswordPolicy() + .then((response) => { + setPasswordValidationRules(response); + }) + .catch((error) => { + console.error("Error fetching password policy:", error); + }); + }, []); + + const getRoleIdByName = async (roleName) => { + const requestConfig = { + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/v2/Roles?filter=displayName eq ${encodeURIComponent(roleName)}`, + headers: { + Accept: "application/scim+json", + }, + }; + + try { + const response = await httpSwitch.request(requestConfig); + const resources = response.data?.Resources || []; + return resources.length > 0 ? resources[0].id : null; + } catch (error) { + console.error("Error fetching role ID:", error); + return null; + } + }; + + const handleSubmit = async (e) => { + + e.preventDefault(); + try { + const valuePayload = { + schemas: [], + name: {}, + userName: "", + emails: [], + "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {}, + "urn:scim:wso2:schema": {}, + "urn:scim:schemas:extension:custom:User": { + accountType: "Business" + }, + }; + + if (formData.givenName.trim() !== "") valuePayload.name.givenName = formData.givenName; + if (formData.familyName.trim() !== "") valuePayload.name.familyName = formData.familyName; + + if (formData.username.trim() !== "") { + valuePayload.userName = `DEFAULT/${formData.username}@${businessName}.com`; + } + + if (formData.email.trim() !== "") { + valuePayload.emails = [{ value: formData.email, primary: true }]; + } + + if (passwordOption === "set" && formData.password.trim() !== "") { + valuePayload.password = formData.password; + } else if (passwordOption === "invite") { + valuePayload["urn:scim:wso2:schema"].askPassword = true; + } + + if (formData.mobile.trim() !== "") { + valuePayload.phoneNumbers = [{ type: "mobile", value: formData.mobile }]; + } + + if (formData.dob.trim() !== "") { + valuePayload["urn:scim:wso2:schema"].dateOfBirth = formData.dob; + } + + if (formData.country.trim() !== "") { + valuePayload["urn:scim:wso2:schema"].country = formData.country; + } + + const response = await request({ + headers: { + "Accept": "application/scim+json", + "Content-Type": "application/scim+json" + }, + method: "POST", + data: valuePayload, + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/Users` + }); + + if (response.status === 201) { + const newRoleId = await getRoleIdByName("Member"); + await httpSwitch.request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/v2/Roles/${newRoleId}`, + headers: { + Accept: "application/scim+json", + "Content-Type": "application/scim+json", + }, + data: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: "add", + path: "users", + value: [{ value: response.data.id }] + } + ] + } + }); + enqueueSnackbar("User created successfully", { variant: "success" }); + } + onCancel(); + } catch (error) { + enqueueSnackbar("Something went wrong while creating user", { variant: "error" }); + console.error(error); + } + }; + + return ( + <> +
+
+
    +
  • + +
    +
    + setFormData({ ...formData, username: e.target.value })} required /> +
    +
    + @{businessName}.com +
    +
    +
  • +
  • +
    +
    + + setFormData({ ...formData, givenName: e.target.value })} required /> +
    +
    + + setFormData({ ...formData, familyName: e.target.value })} required /> +
    +
    +
  • +
  • + + setFormData({ ...formData, dob: e.target.value })} required /> +
  • +
  • + + setFormData({ ...formData, email: e.target.value })} required /> +
  • +
  • + + setFormData({ ...formData, country: value.label })} /> +
  • +
  • + + setFormData({ ...formData, mobile: e.target.value })} required /> +
  • +
  • + Select the method to set the user password +
  • +
  • + +
  • +
  • + +
  • + {passwordOption === "set" && ( +
  • + + + setFormData({ ...formData, password: value }) + } + showPasswordValidation={true} + passwordValidationRules={passwordValidationRules} + onPasswordValidate={(isValid) => { + setIsNewPasswordValid(isValid); + }} + inputProps={{ + autoComplete: "new-password", + }} + /> +
  • + )} +
+
+ + +
+
+
+ + ); +} + +AddUser.propTypes = { + onCancel: PropTypes.func.isRequired +}; + +export default AddUser; diff --git a/app/src/components/business-user-profile/business-member-content.jsx b/app/src/components/business-user-profile/business-member-content.jsx new file mode 100644 index 0000000..10f2792 --- /dev/null +++ b/app/src/components/business-user-profile/business-member-content.jsx @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect } from "react"; +import PropTypes from "prop-types"; +import { SITE_SECTIONS } from "../../constants/app-constants"; + +const BusinessMemberContent = ({ setSiteSection }) => { + + useEffect(() => { + setSiteSection(SITE_SECTIONS.BUSINESS); + }, []); + + return ( + <> +
+
+
+
+
+
+

Business Member Account

+
+

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do + eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut + enim ad minim veniam, quis nostrud exercitation ullamco laboris + nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor + in reprehenderit in voluptate velit +

+ +
+
+
+
+
+ + ); +} + +BusinessMemberContent.propTypes = { + setSiteSection: PropTypes.object.isRequired, +}; + +export default BusinessMemberContent; diff --git a/app/src/components/business-user-profile/business-profile-card.jsx b/app/src/components/business-user-profile/business-profile-card.jsx new file mode 100644 index 0000000..cfc964a --- /dev/null +++ b/app/src/components/business-user-profile/business-profile-card.jsx @@ -0,0 +1,122 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import PropTypes from "prop-types"; +import { useEffect, useState } from "react"; +import { environmentConfig } from "../../util/environment-util"; +import { enqueueSnackbar } from "notistack"; +import axios from "axios"; + +const BusinessProfileCard = ({ userInfo, organizationId }) => { + + const [metadata, setMetadata] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [businessRegNo, setBusinessRegNo] = useState(""); + + const fetchMetadata = async () => { + if (metadata) return; + try { + const response = await axios.get( + `${environmentConfig.API_SERVICE_URL}/business?organizationId=${organizationId}` + ); + if (response.status == 200) { + setMetadata(response.data); + } + } catch (err) { + enqueueSnackbar("Something went wrong while fetching business profile", { variant: "error" }); + console.error(err); + } + }; + + const updateBusinessRegNo = async () => { + try { + const operation = metadata?.businessRegistrationNumber + ? "REPLACE" + : "ADD"; + + const response = await axios.patch( + `${environmentConfig.API_SERVICE_URL}/business-update`, + { + organizationId, + businessRegistrationNumber: businessRegNo, + operation + } + ); + + if (response.status === 200) { + enqueueSnackbar("Business registration number updated successfully", { variant: "success" }); + setMetadata((prev) => ({ + ...prev, + businessRegistrationNumber: businessRegNo, + })); + setIsEditing(false); + } + } catch (err) { + enqueueSnackbar("Failed to update business registration number", { variant: "error" }); + console.error(err); + } + }; + + useEffect(() => { + fetchMetadata(); + }, []); + + return ( +
+
Business Profile
+
    +
  • + Business Name: {userInfo.businessName} +
  • +
  • + Business Registration Number:{" "} + {/* {metadata?.businessRegistrationNumber || "Not set"} */} + {!isEditing && ( + <> + {metadata?.businessRegistrationNumber || "N/A"} + + )} +
  • + {isEditing ? ( + <> + setBusinessRegNo(e.target.value)} + placeholder={metadata?.businessRegistrationNumber || "Not set yet"} + /> + + + + ) : ( + <> + + + )} +
+
+ ); +}; + +BusinessProfileCard.propTypes = { + userInfo: PropTypes.object.isRequired, + organizationId: PropTypes.object.isRequired, + setShowEditForm: PropTypes.func.isRequired, +}; + +export default BusinessProfileCard; diff --git a/app/src/components/business-user-profile/close-business-account-card.jsx b/app/src/components/business-user-profile/close-business-account-card.jsx new file mode 100644 index 0000000..71f52bb --- /dev/null +++ b/app/src/components/business-user-profile/close-business-account-card.jsx @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { useAsgardeo } from "@asgardeo/react"; +import { useSnackbar } from "notistack"; +import Modal from "../common/modal"; +import { closeBusinessAccount } from "../../api/profile"; + +const CloseBusinessAccountCard = ({ businessName }) => { + const { signOut } = useAsgardeo(); + const { enqueueSnackbar } = useSnackbar(); + + const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); + const [isSuccessModalOpen, setIsSuccessModalOpen] = useState(false); + + const handleAccountClose = async () => { + try { + setIsConfirmationOpen(false); + const response = await closeBusinessAccount(businessName); + + if (response.status == 200) { + setIsSuccessModalOpen(true); + } + } catch (err) { + console.error("Error Closing Account:", err); + enqueueSnackbar("Something went wrong while closing account", { + variant: "error", + }); + } + }; + + const onSuccessModalClose = () => { + setIsSuccessModalOpen(false); + signOut(); + }; + + return ( + <> +
+
Close Business Account
+

+ Once you close the account, you cannot recover it again. All business and + user information will be lost. +

+
+ +
+
+ setIsConfirmationOpen(false)} + primaryActionText="Close Account" + primaryActionHandler={() => handleAccountClose()} + secondaryActionText="Cancel" + secondaryActionHandler={() => setIsConfirmationOpen(false)} + title={"Are you sure?"} + message={ + "Are you sure you want to close your account? This action cannot be undone." + } + /> + onSuccessModalClose()} + title={"Account Closed"} + message={ + "Your account has been closed successfully. You will be signed out." + } + primaryActionText="OK" + primaryActionHandler={() => onSuccessModalClose()} + /> + + ); +}; + +CloseBusinessAccountCard.propTypes = { + businessName: PropTypes.string.isRequired +}; + +export default CloseBusinessAccountCard; diff --git a/app/src/components/business-user-profile/idp-form.jsx b/app/src/components/business-user-profile/idp-form.jsx new file mode 100644 index 0000000..dfc998f --- /dev/null +++ b/app/src/components/business-user-profile/idp-form.jsx @@ -0,0 +1,295 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from "react"; +import { TextField, MenuItem } from "@mui/material"; +import PropTypes from "prop-types"; +import { environmentConfig } from "../../util/environment-util"; +import { enqueueSnackbar } from "notistack"; +import { useHttpSwitch } from "../../sdk/httpSwitch"; + +const IDPForm = ({ organizationId, onIdpAdded, fetchAppConfig, getAppId, onCancel }) => { + const [clientId, setClientId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [authorizeUrl, setAuthorizeUrl] = useState(""); + const [tokenUrl, setTokenUrl] = useState(""); + const [provider, setProvider] = useState("microsoft"); + const [loading, setLoading] = useState(false); + const httpSwitch = useHttpSwitch(); + const request = (requestConfig) => + httpSwitch.request(requestConfig) + .then((response) => response) + .catch((error) => error); + + const addAuthenticatorToStep1 = (sequence, providerName) => { + const authenticatorMap = { + microsoft: { authenticator: "OpenIDConnectAuthenticator", idp: "Microsoft" }, + google: { authenticator: "GoogleOIDCAuthenticator", idp: "Google" }, + oidc: { authenticator: "OpenIDConnectAuthenticator", idp: "MyOIDCConnection" }, + }; + + const newOption = authenticatorMap[providerName]; + const updatedSteps = sequence.steps.map((step) => { + if (step.id === 1) { + const options = step.options || []; + const alreadyExists = options.some( + (opt) => opt.idp === newOption.idp && opt.authenticator === newOption.authenticator + ); + if (!alreadyExists) { + options.push(newOption); + } + return { ...step, options }; + } + return step; + }); + return { ...sequence, steps: updatedSteps }; + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + + let body; + + if (provider === "microsoft") { + body = { + image: "assets/images/logos/microsoft.svg", + isPrimary: false, + roles: { mappings: [], outboundProvisioningRoles: [] }, + name: "Microsoft", + certificate: { certificates: [] }, + claims: { + userIdClaim: { uri: "http://wso2.org/claims/username" }, + roleClaim: { uri: "http://wso2.org/claims/role" }, + provisioningClaims: [], + }, + description: "Enable login for users with existing Microsoft accounts.", + alias: "https://localhost:9444/oauth2/token", + homeRealmIdentifier: "", + provisioning: { + jit: { userstore: "DEFAULT", scheme: "PROVISION_SILENTLY", isEnabled: true }, + }, + federatedAuthenticators: { + defaultAuthenticatorId: "T3BlbklEQ29ubmVjdEF1dGhlbnRpY2F0b3I", + authenticators: [ + { + isEnabled: true, + authenticatorId: "T3BlbklEQ29ubmVjdEF1dGhlbnRpY2F0b3I", + properties: [ + { value: clientId, key: "ClientId" }, + { value: clientSecret, key: "ClientSecret" }, + { value: `https://api.asgardeo.io/o/${organizationId}/commonauth`, key: "callbackUrl" }, + { value: "email openid profile", key: "Scopes" }, + { value: "", key: "commonAuthQueryParams" }, + { value: "true", key: "UsePrimaryEmail" }, + { value: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", key: "OAuth2AuthzEPUrl" }, + { value: "https://login.microsoftonline.com/common/oauth2/v2.0/token", key: "OAuth2TokenEPUrl" }, + ], + }, + ], + }, + isFederationHub: false, + templateId: "microsoft-idp", + }; + } else if (provider === "google") { + body = { + image: "assets/images/logos/google.svg", + isPrimary: false, + roles: { mappings: [], outboundProvisioningRoles: [] }, + name: "Google", + certificate: { certificates: [] }, + claims: { + userIdClaim: { uri: "http://wso2.org/claims/username" }, + roleClaim: { uri: "http://wso2.org/claims/role" }, + provisioningClaims: [], + }, + description: "Login users with existing Google accounts.", + alias: "https://localhost:9444/oauth2/token", + homeRealmIdentifier: "", + provisioning: { + jit: { userstore: "DEFAULT", scheme: "PROVISION_SILENTLY", isEnabled: true }, + }, + federatedAuthenticators: { + defaultAuthenticatorId: "R29vZ2xlT0lEQ0F1dGhlbnRpY2F0b3I", + authenticators: [ + { + isEnabled: true, + authenticatorId: "R29vZ2xlT0lEQ0F1dGhlbnRpY2F0b3I", + properties: [ + { value: clientId, key: "ClientId" }, + { value: clientSecret, key: "ClientSecret" }, + { value: `https://api.asgardeo.io/o/${organizationId}/commonauth`, key: "callbackUrl" }, + { value: "scope=email openid profile", key: "AdditionalQueryParameters" }, + ], + }, + ], + }, + isFederationHub: false, + templateId: "google-idp", + }; + } else if (provider === "oidc") { + body = { + image: "assets/images/logos/enterprise.svg", + isPrimary: false, + roles: { mappings: [], outboundProvisioningRoles: [] }, + certificate: { jwksUri: "", certificates: [""] }, + claims: { + userIdClaim: { uri: "" }, + provisioningClaims: [], + roleClaim: { uri: "" }, + }, + name: "MyOIDCConnection", + description: "Authenticate users with Enterprise OIDC connections.", + federatedAuthenticators: { + defaultAuthenticatorId: "T3BlbklEQ29ubmVjdEF1dGhlbnRpY2F0b3I", + authenticators: [ + { + isEnabled: true, + authenticatorId: "T3BlbklEQ29ubmVjdEF1dGhlbnRpY2F0b3I", + properties: [ + { key: "ClientId", value: clientId }, + { key: "ClientSecret", value: clientSecret }, + { key: "OAuth2AuthzEPUrl", value: authorizeUrl }, + { key: "OAuth2TokenEPUrl", value: tokenUrl }, + { key: "callbackUrl", value: `https://api.asgardeo.io/o/${organizationId}/commonauth` }, + ], + }, + ], + }, + homeRealmIdentifier: "", + provisioning: { + jit: { userstore: "DEFAULT", scheme: "PROVISION_SILENTLY", isEnabled: true }, + }, + isFederationHub: false, + templateId: "enterprise-oidc-idp", + }; + } + + try { + await request({ + method: "POST", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/identity-providers`, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + data: body, + }).then((response) => { + // eslint-disable-next-line no-console + console.log(response); + }) + + const appData = await fetchAppConfig(); + const updatedSequence = addAuthenticatorToStep1(appData.authenticationSequence, provider); + const applicationId = await getAppId(); + await request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications/${applicationId}`, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + data: { authenticationSequence: updatedSequence }, + }); + if (onIdpAdded) onIdpAdded(); + } catch (error) { + enqueueSnackbar("Something went wrong while creating idp", { variant: "error" }); + console.error(error); + } finally { + setLoading(false); + } + }; + + return ( + <> +
+ setProvider(e.target.value)} + > + Microsoft + Google + OIDC + + + setClientId(e.target.value)} + required + className="mui-text-field" + /> + + setClientSecret(e.target.value)} + required + /> + + {provider === "oidc" && ( + <> + setAuthorizeUrl(e.target.value)} + required + /> + + setTokenUrl(e.target.value)} + required + /> + + )} + +
+ + +
+ + + ); +} + +IDPForm.propTypes = { + organizationId: PropTypes.string.isRequired, + onIdpAdded: PropTypes.func.isRequired, + fetchAppConfig: PropTypes.func.isRequired, + getAppId: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired +}; + +export default IDPForm; \ No newline at end of file diff --git a/app/src/components/business-user-profile/idp-list.jsx b/app/src/components/business-user-profile/idp-list.jsx new file mode 100644 index 0000000..e9f4f5c --- /dev/null +++ b/app/src/components/business-user-profile/idp-list.jsx @@ -0,0 +1,361 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from "react"; +import { + Typography, + IconButton, + List, + ListItem, + ListItemText, + CircularProgress, + Dialog, + DialogTitle, + DialogContent, + Tooltip, +} from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { environmentConfig } from "../../util/environment-util"; +import { useAsgardeo, useOrganization, useUser } from "@asgardeo/react"; +import IDPForm from "./idp-form"; +import PropTypes from "prop-types"; +import { enqueueSnackbar } from "notistack"; +import { useHttpSwitch } from "../../sdk/httpSwitch"; + +const IDPList = () => { + + const [ idps, setIdps ] = useState([]); + const [ loading, setLoading ] = useState(true); + const [ openForm, setOpenForm ] = useState(false); + const { isSignedIn } = useAsgardeo(); + const { myOrganizations } = useOrganization(); + const { flattenedProfile } = useUser(); + const [ organizationId, setOrganizationId ] = useState(""); + const [ deletingIdpId, setDeletingIdpId ] = useState(null); + const [ mfaOptions, setMfaOptions ] = useState({ + totp: false, + emailOTP: false, + }); + const [loadingMFA, setLoadingMFA] = useState(false); + const httpSwitch = useHttpSwitch(); + const cache = { applicationId: null }; + + const request = (requestConfig) => + httpSwitch.request(requestConfig) + .then((response) => response) + .catch((error) => error); + + const fetchIdps = async () => { + try { + setLoading(true); + const response = await request({ + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/identity-providers`, + method: "GET", + headers: { + Accept: "application/json", + }, + }); + setIdps(response.data?.identityProviders || []); + } catch (err) { + enqueueSnackbar("Something went wrong while fetching IdPs", { variant: "error" }); + console.error(err); + } finally { + setLoading(false); + } + }; + + const getAppId = async () => { + if (cache.applicationId) { + return cache.applicationId; + } + + const response = await request({ + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications?filter=name%20eq%20${encodeURIComponent(environmentConfig.APP_NAME)}`, + headers: { + Accept: "application/json", + }, + }); + + if (response?.data?.applications?.length > 0) { + cache.applicationId = response.data.applications[0].id; + return cache.applicationId; + } else { + enqueueSnackbar("Something went wrong while fetching app details", { variant: "error" }); + } + }; + + const fetchAppConfig = async () => { + const applicationId = await getAppId(); + const response = await request({ + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications/${applicationId}`, + headers: { Accept: "application/json" }, + }); + return response.data; + }; + + const removeAuthenticatorFromStep1 = (sequence, idpName) => { + const updatedSteps = sequence.steps.map((step) => { + if (step.id === 1) { + return { + ...step, + options: (step.options || []).filter((opt) => opt.idp !== idpName), + }; + } + return step; + }); + return { ...sequence, steps: updatedSteps }; + }; + + const deleteIdp = async (id, name) => { + setDeletingIdpId(id); + try { + const appData = await fetchAppConfig(); + const applicationId = await getAppId(); + const updatedSequence = removeAuthenticatorFromStep1(appData.authenticationSequence, name); + + await request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications/${applicationId}`, + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + data: { authenticationSequence: updatedSequence }, + }); + await request( + { + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/identity-providers/${id}?force=false`, + method: "DELETE", + headers: { + Accept: "*/*", + }, + } + ); + setIdps(idps.filter((idp) => idp.id !== id)); + } catch (err) { + enqueueSnackbar("Something went wrong while deleting IdP", { variant: "error" }); + console.error(err); + } finally { + setDeletingIdpId(null); + } + }; + + const handleOptionChange = (e) => { + const { name, checked } = e.target; + setMfaOptions({ ...mfaOptions, [name]: checked }); + }; + + const fetchMFA = async () => { + try { + setLoadingMFA(true); + const response = await request({ + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications/b1af46d8-af32-474c-8235-3a52f9e2d526/authenticators`, + headers: { + Accept: "application/json", + }, + }); + const steps = response.data || []; + const step2 = steps.find((step) => step.stepId === 2); + const options = { totp: false, emailOTP: false }; + if (step2) { + const localAuths = step2.localAuthenticators || []; + if (localAuths.some((auth) => auth.type === "totp")) { + options.totp = true; + } + if (localAuths.some((auth) => auth.type === "email-otp-authenticator")) { + options.emailOTP = true; + } + } + + setMfaOptions(options); + } catch (err) { + console.error("Error fetching MFA config:", err); + enqueueSnackbar("Failed to load MFA settings.", { variant: "error" }); + } finally { + setLoadingMFA(false); + } + }; + + const handleSave = async () => { + setLoadingMFA(true); + + try { + const appData = await fetchAppConfig(); + const existingSteps = appData.authenticationSequence.steps || []; + + const step1 = existingSteps.find(step => step.id === 1) || { + id: 1, + options: [] + }; + + const step2Options = []; + if (mfaOptions.totp) step2Options.push({ authenticator: "totp", idp: "LOCAL" }); + if (mfaOptions.emailOTP) step2Options.push({ authenticator: "email-otp-authenticator", idp: "LOCAL" }); + + const steps = [step1]; + if (step2Options.length > 0) { + steps.push({ + id: 2, + options: step2Options + }); + } + + const payload = { + authenticationSequence: { + attributeStepId: 1, + requestPathAuthenticators: [], + steps: steps, + subjectStepId: 1, + type: "USER_DEFINED", + script: "" + } + }; + + + const response = await request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/api/server/v1/applications/b1af46d8-af32-474c-8235-3a52f9e2d526`, + data: payload, + headers: { + Accept: "application/json", + "Content-Type": "application/json" + } + }); + + if (response.status != 200) throw new Error(`Failed to update MFA: ${response.statusText}`); + enqueueSnackbar("MFA settings updated successfully!", { variant: "success" }); + } catch (err) { + console.error(err); + enqueueSnackbar("Something went wrong while saving MFA settings.", { variant: "error" }); + } finally { + setLoadingMFA(false); + } + }; + + useEffect(() => { + fetchIdps(); + fetchMFA(); + }, []); + + useEffect(() => { + if (!isSignedIn) { + return; + } + const businessOrg = myOrganizations.find( + (org) => org.name === flattenedProfile?.businessName + ); + if (!businessOrg) { + return; + } + setOrganizationId(businessOrg.id); + }, []); + + return ( + <> +
+
Configure Authentication
+ + {loading ? ( + + ) : idps.length === 0 ? ( + + No IdP configured + + ) : ( + + {idps.map((idp) => ( + + ) : ( + deleteIdp(idp.id, idp.name)}> + + + ) + } + > + + + ))} + + )} + + = 1 ? "Only 1 Identity Provider is allowed per business account." : ""} arrow> + + + + + + +
+ + +
+ + + setOpenForm(false)} maxWidth="sm" fullWidth> + Add Identity Provider + + { + fetchIdps(); + setOpenForm(false); + }} getAppId={getAppId} fetchAppConfig={fetchAppConfig} onCancel={() => setOpenForm(false)}/> + + +
+ + ); +} + +IDPList.propTypes = { + organizationId: PropTypes.string.isRequired, +}; + +export default IDPList; diff --git a/app/src/components/business-user-profile/list-users.jsx b/app/src/components/business-user-profile/list-users.jsx new file mode 100644 index 0000000..2f61e8d --- /dev/null +++ b/app/src/components/business-user-profile/list-users.jsx @@ -0,0 +1,443 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import * as React from 'react'; +import { DataGrid } from '@mui/x-data-grid'; +import Paper from '@mui/material/Paper'; +import { useEffect } from 'react'; +import { environmentConfig } from '../../util/environment-util'; +import { useUser } from '@asgardeo/react'; +import { useHttpSwitch } from '../../sdk/httpSwitch'; +import { Box, CircularProgress, IconButton, MenuItem, Select, TextField } from '@mui/material'; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import CountrySelect from '../../components/country-select'; +import { enqueueSnackbar } from 'notistack'; + +const ListUsers = () => { + + const { profile } = useUser(); + const [rows, setRows] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [editingUser, setEditingUser] = React.useState(null); + const paginationModel = { page: 0, pageSize: 5 }; + const [deletingUserId, setDeletingUserId] = React.useState(null); + const [assigningRoleUserId, setAssigningRoleUserId] = React.useState(null); + + const columns = [ + { field: 'id', headerName: 'ID', width: 280, sortable: false }, + { field: 'username', headerName: 'Username', width: 220 }, + { field: 'givenName', headerName: 'First Name', width: 160 }, + { field: 'familyName', headerName: 'Last Name', width: 160 }, + { field: 'email', headerName: 'Email', width: 220 }, + { + field: "assignRole", + headerName: "Assign Role", + width: 140, + sortable: false, + renderCell: (params) => ( + + + + {assigningRoleUserId === params.row.id && ( + + )} + + ), + }, + { + field: "actions", + headerName: "Actions", + width: 100, + sortable: false, + renderCell: (params) => ( + <> + setEditingUser(params.row)} + > + + + handleDelete(params.row.id)} + disabled={deletingUserId === params.row.id} + > + {deletingUserId === params.row.id ? ( + + ) : ( + + )} + + + ), + }, + ]; + + function cleanUsername(rawUsername = "") { + return rawUsername.includes("/") ? rawUsername.split("/")[1] : rawUsername; + } + + function transformUsers(data) { + if (!data.Resources) return []; + + return data.Resources + .filter((user) => cleanUsername(user.userName) !== profile.userName) + .map((user) => ({ + id: user.id, + username: cleanUsername(user.userName), + givenName: user.name?.givenName ?? "", + familyName: user.name?.familyName ?? "", + email: user.emails?.[0] ?? "", + role: user.roles?.[0]?.display ?? "", + dateOfBirth: user["urn:scim:wso2:schema"].dateOfBirth ?? "", + country: user["urn:scim:wso2:schema"].country ?? "", + mobile: user.phoneNumbers?.[0]?.value ?? "" + })); + } + + const httpSwitch = useHttpSwitch(); + + useEffect(() => { + const requestConfig = { + headers: { + Accept: "application/scim+json", + "Content-Type": "application/scim+json", + }, + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/Users`, + }; + + httpSwitch + .request(requestConfig) + .then((response) => { + setRows(transformUsers(response.data)); + }) + .catch((error) => { + console.error("Error fetching SCIM users:", error); + }) + .finally(() => setLoading(false)); + }, []); + + const handleDelete = async (userId) => { + + try { + setDeletingUserId(userId); + const requestConfig = { + method: "DELETE", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/Users/${userId}`, + headers: { + Accept: "*/*", + "Content-Type": "application/scim+json", + }, + }; + const response = await httpSwitch.request(requestConfig); + if (response.status === 204) { + enqueueSnackbar("User deleted successfully", { variant: "success" }); + setRows((prevRows) => prevRows.filter((row) => row.id !== userId)); + } + } catch (error) { + console.error("Error deleting user:", error); + enqueueSnackbar("Failed to delete user", { variant: "error" }); + } finally { + setDeletingUserId(null); + } + }; + + const handleSave = async (updatedUser) => { + + const patchValue = { + name: { + givenName: updatedUser.givenName, + familyName: updatedUser.familyName, + }, + emails: [updatedUser.email], + "urn:scim:wso2:schema": { + dateOfBirth: updatedUser.dateOfBirth, + country: updatedUser.country, + }, + phoneNumbers: [{ type: "mobile", value: updatedUser.mobile }], + }; + if (updatedUser.password && updatedUser.password.trim() !== "") { + patchValue.password = updatedUser.password; + } + + const requestConfig = { + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/Users/${updatedUser.id}`, + headers: { + Accept: "application/scim+json", + "Content-Type": "application/scim+json", + }, + data: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: "replace", + value: patchValue, + }, + ], + }, + }; + + try { + const response = await httpSwitch.request(requestConfig); + setRows((prevRows) => + prevRows.map((row) => + row.id === updatedUser.id ? updatedUser : row + ) + ); + if (response.status === 200) { + enqueueSnackbar("User updated successfully", { variant: "success" }); + } + setEditingUser(null); + } catch (error) { + console.error("Error updating user:", error); + enqueueSnackbar("Error updating user", { variant: "error" }); + } + }; + + const handleRoleSelect = async (newRoleName, selectedUser) => { + + setAssigningRoleUserId(selectedUser.id); + setRows((prev) => + prev.map((row) => + row.id === selectedUser.id ? { ...row, role: newRoleName } : row + ) + ); + try { + const currentRoleId = await getRoleIdByName(selectedUser.role); + const newRoleId = await getRoleIdByName(newRoleName); + + if (!newRoleId) { + console.error(`Role ID not found for role: ${newRoleName}`); + return; + } + + if (currentRoleId) { + await httpSwitch.request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/v2/Roles/${currentRoleId}`, + headers: { + Accept: "application/scim+json", + "Content-Type": "application/scim+json", + }, + data: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: "remove", + path: `users[value eq "${selectedUser.id}"]` + } + ] + } + }); + } + + await httpSwitch.request({ + method: "PATCH", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/v2/Roles/${newRoleId}`, + headers: { + Accept: "application/scim+json", + "Content-Type": "application/scim+json", + }, + data: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: "add", + path: "users", + value: [{ value: selectedUser.id }] + } + ] + } + }); + + } catch (error) { + console.error("Error switching role:", error); + } finally { + setAssigningRoleUserId(null); + } +}; + + const getRoleIdByName = async (roleName) => { + const requestConfig = { + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/o/scim2/v2/Roles?filter=displayName eq ${encodeURIComponent(roleName)}`, + headers: { + Accept: "application/scim+json", + }, + }; + + try { + const response = await httpSwitch.request(requestConfig); + const resources = response.data?.Resources || []; + return resources.length > 0 ? resources[0].id : null; + } catch (error) { + console.error("Error fetching role ID:", error); + return null; + } + }; + + if (editingUser) { + return ( + + + theme.palette.action.disabledBackground + }, + }} + /> + + + setEditingUser({ ...editingUser, givenName: e.target.value }) + } + /> + + setEditingUser({ ...editingUser, familyName: e.target.value }) + } + /> + + + setEditingUser({ ...editingUser, dateOfBirth: e.target.value }) + } + /> + + + setEditingUser({ ...editingUser, email: e.target.value }) + } + /> + + setEditingUser({ ...editingUser, country: e.label }) + } + /> + + setEditingUser({ ...editingUser, mobile: e.target.value }) + } + /> + + setEditingUser({ ...editingUser, password: e.target.value }) + } + /> + + + + + + + ); + } + + return ( + + + + ); +} + +export default ListUsers; diff --git a/app/src/components/business-user-profile/manage-users.jsx b/app/src/components/business-user-profile/manage-users.jsx new file mode 100644 index 0000000..6396c92 --- /dev/null +++ b/app/src/components/business-user-profile/manage-users.jsx @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useState } from "react"; +import AddUser from "./add-user"; +import { Button, Dialog, DialogActions, DialogContent } from "@mui/material"; +import ListUsers from "./list-users"; + +const ManageUsers = () => { + + const [openForm, setOpenForm] = useState(false); + const [openView, setOpenView] = useState(false); + + const handleCancelEdit = () => { + setOpenForm(false); + }; + + return ( + <> +
+
Manage Users
+

+ Add, edit or delete users in your business account. +

+
+ +
+
+ +
+
+ setOpenView(false)} maxWidth="xl" fullWidth> + + + + + + + + setOpenForm(false)} maxWidth="sm" fullWidth> + + + + + + ); +}; + +export default ManageUsers; diff --git a/app/src/components/country-select.jsx b/app/src/components/country-select.jsx index 1418fff..f9cabf2 100644 --- a/app/src/components/country-select.jsx +++ b/app/src/components/country-select.jsx @@ -448,7 +448,7 @@ const countries = [ { code: 'ZW', label: 'Zimbabwe', phone: '263' }, ]; -const CountrySelect = ({ value, onChange }) => { +const CountrySelect = ({ value, onChange, label = "" }) => { const [ selectedCountry, setSelectedCountry ] = useState(null); useEffect(() => { @@ -498,6 +498,7 @@ const CountrySelect = ({ value, onChange }) => { renderInput={(params) => ( { CountrySelect.propTypes = { onChange: PropTypes.object.isRequired, value: PropTypes.object.isOptional, - key: PropTypes.object.isRequired + key: PropTypes.object.isRequired, + label: PropTypes.string }; export default CountrySelect; diff --git a/app/src/components/user-profile/view-profile.jsx b/app/src/components/user-profile/view-profile.jsx index 602f3ba..676f139 100644 --- a/app/src/components/user-profile/view-profile.jsx +++ b/app/src/components/user-profile/view-profile.jsx @@ -32,7 +32,7 @@ const ViewProfile = ({ userInfo, setShowEditForm }) => {
- +
{ +const BankAccountCard = ({ userInfo }) => { const initialCreditCardState = { cardNumber: "4574-3434-2984-2365", balance: -45600.67, @@ -97,7 +98,11 @@ const BankAccountCard = ({ userId }) => { - + {userInfo.accountType === ACCOUNT_TYPES.BUSINESS ? ( + + ) : ( + + )}
@@ -105,7 +110,7 @@ const BankAccountCard = ({ userId }) => { }; BankAccountCard.propTypes = { - userId: PropTypes.string.isRequired, + userInfo: PropTypes.object.isRequired, }; export default BankAccountCard; diff --git a/app/src/constants/app-constants.jsx b/app/src/constants/app-constants.jsx index c1d83f4..d8a2a4d 100644 --- a/app/src/constants/app-constants.jsx +++ b/app/src/constants/app-constants.jsx @@ -21,6 +21,7 @@ export const ROUTES = { PERSONAL_BANKING: "/personal-banking", BUSINESS_BANKING: "/business-banking", USER_PROFILE: "/user-profile", + BUSINESS_PROFILE: "/business-profile", REGISTER_ACCOUNT: "/register-account", FUND_TRANSFER: "/fund-transfer", FUND_TRANSFER_VERIFY: "/fund-transfer/verify", @@ -35,7 +36,8 @@ export const SITE_SECTIONS = { export const ACCOUNT_TYPES = { PERSONAL: "Personal", - BUSINESS: "Business" + BUSINESS: "Business", + BUSINESS_MEMBER: "BusinessMember" } export const URL_QUERY_PARAMS = { diff --git a/app/src/pages/business-profile.jsx b/app/src/pages/business-profile.jsx new file mode 100644 index 0000000..e663676 --- /dev/null +++ b/app/src/pages/business-profile.jsx @@ -0,0 +1,197 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useAsgardeo, useOrganization, useUser } from "@asgardeo/react"; +import EditProfile from "../components/user-profile/edit-profile"; +import ViewProfile from "../components/user-profile/view-profile"; +import { ACCOUNT_TYPES, SITE_SECTIONS } from "../constants/app-constants"; +import { environmentConfig } from "../util/environment-util"; +import IdentityVerificationStatus from "../components/identity-verification/identity-verification-status"; +import { useContext } from "react"; +import { IdentityVerificationContext } from "../context/identity-verification-provider"; +import ContextSwitch from "../sdk/ContextSwitch"; +import BusinessMemberContent from "../components/business-user-profile/business-member-content"; +import IDPList from "../components/business-user-profile/idp-list"; +import ManageUsers from "../components/business-user-profile/manage-users"; +import BusinessProfileCard from "../components/business-user-profile/business-profile-card"; + +const BusinessProfilePage = ({ setSiteSection }) => { + const { isSignedIn, signIn, http } = useAsgardeo(); + const { isIdentityVerificationEnabled, reloadIdentityVerificationStatus } = useContext(IdentityVerificationContext); + + const [userInfo, setUserInfo] = useState(null); + const [showEditForm, setShowEditForm] = useState(false); + const { myOrganizations } = useOrganization(); + const { flattenedProfile } = useUser(); + const [ organizationId, setOrganizationId ] = useState(""); + const scopes = "openid profile internal_login internal_org_application_mgt_update internal_org_application_mgt_delete internal_org_application_mgt_create internal_org_application_mgt_view internal_org_user_mgt_update internal_org_user_mgt_delete internal_org_user_mgt_list internal_org_user_mgt_create internal_org_user_mgt_view internal_org_idp_view internal_org_idp_delete internal_org_idp_update internal_org_idp_create internal_org_role_mgt_delete internal_org_role_mgt_create internal_org_role_mgt_update internal_org_role_mgt_view"; + const request = (requestConfig) => + http.request(requestConfig) + .then((response) => response) + .catch((error) => error); + + useEffect(() => { + if (!isSignedIn) { + signIn(); + } + }, []); + + useEffect(() => { + getUserInfo(); + //getIdToken(); // Update after the fix with refresh token + }, []); + + const handleUpdateSuccess = () => { + getUserInfo(); // Remove after the fix with refresh token + reloadIdentityVerificationStatus(); + setShowEditForm(false); + + // updateToken().then(() => { // Use after the fix with refresh token + // getUpdatedUser(); + // setShowEditForm(false); + // }); + }; + + useEffect(() => { + if (!isSignedIn) { + return; + } + const businessOrg = myOrganizations.find( + (org) => org.name === flattenedProfile?.businessName + ); + if (!businessOrg) { + return; + } + setOrganizationId(businessOrg.id); + }, []); + + const getUserInfo = () => { + request({ + headers: { + Accept: "application/json", + "Content-Type": "application/scim+json", + }, + method: "GET", + url: `${environmentConfig.ASGARDEO_BASE_URL}/scim2/Me`, + }).then((response) => { + if (response.data) { + if ( + response.data["urn:scim:schemas:extension:custom:User"] + ?.accountType === ACCOUNT_TYPES.BUSINESS + ) { + setSiteSection(SITE_SECTIONS.BUSINESS); + } else { + setSiteSection(SITE_SECTIONS.PERSONAL); + } + setUserInfo({ + userId: response.data.id || "", + username: response.data.userName || "", + accountType: + response.data["urn:scim:schemas:extension:custom:User"] + .accountType || "N/A", + businessName: response.data["urn:scim:schemas:extension:custom:User"] + .businessName || "N/A", + email: response.data.emails[0] || "", + givenName: response.data.name.givenName || "", + familyName: response.data.name.familyName || "", + mobile: response.data.phoneNumbers[0].value || "", + country: response.data["urn:scim:wso2:schema"].country || "", + birthdate: response.data["urn:scim:wso2:schema"].dateOfBirth || "", + picture: response.data.picture || "", + }); + } + return; + }); + }; + + const handleCancelEdit = () => { + setShowEditForm(false); + }; + + if (!userInfo) { + return; + } + + return ( + <> + {isIdentityVerificationEnabled && } +
+
+ {userInfo && userInfo.accountType === ACCOUNT_TYPES.BUSINESS_MEMBER ? ( + + ) : ( + <> + {showEditForm && userInfo ? ( + + ) : ( + + )} + + )} + + {userInfo && userInfo.accountType === ACCOUNT_TYPES.BUSINESS && ( + +
+
+
+
+
+ +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ )} +
+
+ + ); +}; + +BusinessProfilePage.propTypes = { + setSiteSection: PropTypes.object.isRequired, +}; + +export default BusinessProfilePage; diff --git a/app/src/pages/user-profile.jsx b/app/src/pages/user-profile.jsx index 0f0afd1..e1dde25 100644 --- a/app/src/pages/user-profile.jsx +++ b/app/src/pages/user-profile.jsx @@ -18,7 +18,7 @@ import { useEffect, useState } from "react"; import PropTypes from "prop-types"; -import { useAsgardeo } from "@asgardeo/react"; +import { useAsgardeo, useUser } from "@asgardeo/react"; import EditProfile from "../components/user-profile/edit-profile"; import ViewProfile from "../components/user-profile/view-profile"; import { ACCOUNT_TYPES, SITE_SECTIONS } from "../constants/app-constants"; @@ -124,7 +124,7 @@ const UserProfilePage = ({ setSiteSection }) => { userInfo={userInfo} setShowEditForm={setShowEditForm} /> - )} + )} diff --git a/app/src/sdk/ContextSwitch.jsx b/app/src/sdk/ContextSwitch.jsx new file mode 100644 index 0000000..eb4cc47 --- /dev/null +++ b/app/src/sdk/ContextSwitch.jsx @@ -0,0 +1,118 @@ +import { useEffect, useState, } from 'react'; +import { environmentConfig } from "../util/environment-util"; +import PropTypes from 'prop-types'; +import { useAsgardeo } from '@asgardeo/react'; +import { SwitchTokenContext } from './SwitchTokenContext'; + + +const ContextSwitch = ({ organizationId, children, fallback = null, scopes = "openid profile internal_login" }) => { + + const asgardeo = useAsgardeo(); + const { isSignedIn, getAccessToken, exchangeToken } = asgardeo; + const [ switchToken, setSwitchToken ] = useState(""); + const [ refreshToken, setRefreshToken ] = useState(null); + const [ expiresIn, setExpiresIn ] = useState(null); + + useEffect(() => { + if (!switchToken || switchToken == "") { + handleTokenSwitch(); + } + }, []); + + useEffect(() => { + if (!switchToken || !refreshToken || !expiresIn) return; + + // Refresh 30 seconds before expiry + const refreshTime = (expiresIn - 30) * 1000; + const timer = setTimeout(() => { + handleTokenRefresh(); + }, refreshTime); + + return () => clearTimeout(timer); + }, [switchToken, refreshToken, expiresIn]); + + const handleTokenSwitch = async () => { + if (!isSignedIn) { + return; + } + const loggedInTokenResponse = await getAccessToken(); + const exchangeConfig = { + attachToken: false, + data: { + client_id: `${environmentConfig.APP_CLIENT_ID}`, + grant_type: 'organization_switch', + scope: `${scopes}`, + switching_organization: organizationId, + token: loggedInTokenResponse, + }, + id: 'organization-switch', + returnsSession: false, + signInRequired: true, + }; + const tokenResponse = await exchangeToken(exchangeConfig); + if ("access_token" in tokenResponse && typeof tokenResponse.access_token === "string") { + setSwitchToken(tokenResponse.access_token); + } + if ("refresh_token" in tokenResponse && typeof tokenResponse.refresh_token === "string") { + setRefreshToken(tokenResponse.refresh_token); + } + if ("expires_in" in tokenResponse) { + setExpiresIn(tokenResponse.expires_in); // in seconds + } + }; + + const handleTokenRefresh = async () => { + const refreshConfig = { + attachToken: false, + data: { + client_id: environmentConfig.APP_CLIENT_ID, + grant_type: "refresh_token", + refresh_token: refreshToken, + }, + id: "organization-switch-refresh", + returnsSession: false, + signInRequired: true, + }; + + const tokenResponse = await exchangeToken(refreshConfig); + + if ("access_token" in tokenResponse && typeof tokenResponse.access_token === "string") { + setSwitchToken(tokenResponse.access_token); + } + if ("refresh_token" in tokenResponse && typeof tokenResponse.refresh_token === "string") { + setRefreshToken(tokenResponse.refresh_token); + } + if ("expires_in" in tokenResponse && typeof tokenResponse.expires_in === "string") { + setExpiresIn(tokenResponse.expires_in); // in seconds + } + }; + + + + if (!isSignedIn) { + return <> +

aaaaaaa

+ {fallback} + ; + } + + if (!switchToken) { + return
Loading...
; + } + + // return <>{children}; + return ( + + {children} + + ); +} + +ContextSwitch.propTypes = { + organizationId: PropTypes.string.isRequired, + children: PropTypes.element, + fallback: PropTypes.element, + scopes: PropTypes.string +}; + +export default ContextSwitch; diff --git a/app/src/sdk/SwitchTokenContext.jsx b/app/src/sdk/SwitchTokenContext.jsx new file mode 100644 index 0000000..2a3789b --- /dev/null +++ b/app/src/sdk/SwitchTokenContext.jsx @@ -0,0 +1,4 @@ +import { createContext, useContext } from "react"; + +export const SwitchTokenContext = createContext(null); +export const useSwitchToken = () => useContext(SwitchTokenContext); diff --git a/app/src/sdk/httpSwitch.js b/app/src/sdk/httpSwitch.js new file mode 100644 index 0000000..264ea2b --- /dev/null +++ b/app/src/sdk/httpSwitch.js @@ -0,0 +1,57 @@ +/** + * Copyright (c) 2025, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { useSwitchToken } from './SwitchTokenContext'; +import axios from 'axios'; + +/** + * Custom hook that returns an http client with the switch token + * + * TODO: + * + * When transferring this into the Asgardeo SDK, it would be ideal to + * integrate this with the existing http component such that if a switch + * token is available, i.e., if used within ContextSwitch, the request + * uses the switch token, and if used outside ContextSwitch, the request + * uses the logged in organization's token as usual. + */ +export const useHttpSwitch = () => { + + const switchToken = useSwitchToken(); + if (switchToken === null) { + throw new Error( + "useHttpSwitch must be used within a ContextSwitch provider" + ); + } + + const instance = axios.create({ + headers: { + Authorization: `Bearer ${switchToken}`, + }, + }); + + return { + /** + * Proxy to axios.request with switch token + * @param {object} config - Axios request config + */ + request: (config) => { + return instance.request(config); + }, + }; +}; diff --git a/app/src/util/environment-util.js b/app/src/util/environment-util.js index 6c417f9..ac17c9d 100644 --- a/app/src/util/environment-util.js +++ b/app/src/util/environment-util.js @@ -26,6 +26,7 @@ export const environmentConfig = { ASGARDEO_BASE_URL: window.config && window.config.ASGARDEO_BASE_URL, ORGANIZATION_NAME: window.config && window.config.ORGANIZATION_NAME, APP_CLIENT_ID: window.config && window.config.APP_CLIENT_ID, + APP_NAME: window.config && window.config.APP_NAME, DISABLED_FEATURES: window.config && window.config.DISABLED_FEATURES, TRANSFER_THRESHOLD: window.config && window.config.TRANSFER_THRESHOLD || 10000, IDENTITY_VERIFICATION_PROVIDER_ID: window.config && window.config.IDENTITY_VERIFICATION_PROVIDER_ID, diff --git a/script/conditional-auth-script.js b/script/conditional-auth-script.js new file mode 100644 index 0000000..42d12d9 --- /dev/null +++ b/script/conditional-auth-script.js @@ -0,0 +1,203 @@ +var moneyTransferThres = 10000; +var riskEndpoint = "/risk" +var enrolUserInAuthenticationFlow = "false"; +var loginType = ""; + +var onLoginRequest = function(context) { + + var isMoneyTransfer = context.request.params.action && context.request.params.action[0] === "money-transfer"; + if (isMoneyTransfer) { + Log.info("Custom param:" + context.request.params.action[0]); + Log.info("Custom param:" + context.request.params.transfer_amount[0]); + var amount = parseInt(context.request.params.transfer_amount[0] || -1); + + executeStep(1); + if (amount > moneyTransferThres) { + executeStep(4, { + stepOptions: { + forceAuth: 'true' + } + }, {}); + } + + } else { + + var loginTypeParam = context.request.params.loginType; + + if (loginTypeParam != null) { + loginType = String(loginTypeParam[0] || ""); + } + executeStep(1, { + onSuccess: function(context) { + var user = context.steps[1].subject; + var accountType = user.localClaims["http://wso2.org/claims/accountType"]; + var country = user.localClaims["http://wso2.org/claims/country"]; + Log.info("Account Type: " + accountType); + Log.info("Country: " + country); + var ipAddress = context.request.ip; + Log.info("IP Address: " + ipAddress); + var requestPayload = { + ip: ipAddress, + country: country, + }; + if (accountType === "Personal") { + if (loginType != "" && loginType != accountType) { + fail({ + 'errorCode': 'login_failed', + 'errorMessage': 'User not found.', + }); + } + httpPost(riskEndpoint, requestPayload, { + "Accept": "application/json" + }, { + onSuccess: function(context, data) { + Log.info("Successfully invoked the external API."); + Log.info("Logging data for country risk: " + data.hasRisk); + + if (data.hasRisk === false) { + executeStep(2, { + authenticationOptions: [{ + authenticator: 'FIDOAuthenticator' + }, { + authenticator: 'BasicAuthenticator' + }] + }, { + onSuccess: function(context) { + var user = context.currentKnownSubject; + var sessions = getUserSessions(user); + Log.info(sessions); + if (sessions.length > 0) { + executeStep(3, { + authenticationOptions: [{ + authenticator: 'email-otp-authenticator' + }] + }, {}); + } + } + }); + } else { + executeStep(2, { + authenticationOptions: [{ + authenticator: 'FIDOAuthenticator' + }, { + authenticator: 'BasicAuthenticator' + }], + }, {}); + Log.info("In 2nd step for Personal Accounts"); + + executeStep(3, { + authenticationOptions: [{ + authenticator: 'email-otp-authenticator' + }] + }, {}); + } + }, + onFail: function(context, data) { + Log.error("Failed to invoke risk API"); + fail(); + } + }); + } else if (accountType === "Business") { + Log.info("In second step for Business"); + + executeStep(2, { + authenticationOptions: [{ + authenticator: 'BasicAuthenticator' + }] + }, {}); + var preferredClaimURI = "http://wso2.org/claims/identity/preferredMFAOption"; + var preferredClaim = user.localClaims[preferredClaimURI]; + + if (preferredClaim != null) { + Log.info("Preferred Claim Available"); + + var jsonObj = JSON.parse(preferredClaim); + var authenticationOption = jsonObj.authenticationOption; + Log.info("preferredClaim authenticationOption " + authenticationOption); + executeStep(3, { + authenticationOptions: [{ + authenticator: authenticationOption + }], + }, {}); + } else { + Log.info("Preferred claim not available and in 3rd step"); + executeStep(3, { + authenticatorParams: { + common: { + 'enrolUserInAuthenticationFlow': enrolUserInAuthenticationFlow + } + }, + authenticationOptions: [{ + authenticator: 'totp' + }, { + authenticator: 'email-otp-authenticator' + }] + }, { + onSuccess: function(context) { + var preferredClaimURI = "http://wso2.org/claims/identity/preferredMFAOption"; + Log.info("3rd step successful"); + var user = context.steps[3].subject; + var isFirstLogin = user.localClaims["http://wso2.org/claims/isFirstLogin"]; + Log.info("User isFirstLogin claim:" + isFirstLogin); + if (isFirstLogin === "false") { + var authenticatorName = context.steps[3].authenticator; + var preferredMFA = { + authenticationOption: authenticatorName + }; + user.localClaims[preferredClaimURI] = JSON.stringify(preferredMFA); + Log.info("Preferred MFA set from second login for user" + user.username + " as " + user.localClaims[preferredClaimURI]); + } else { + user.localClaims["http://wso2.org/claims/isFirstLogin"] = false; + Log.info("User logged in for the first time. Setting isFirstLogin to false"); + } + } + }); + } + } else { + + var username = user.username; + var organizationName = null; + + if (loginType != "" && loginType === 'Business' && username && username.indexOf("@") > -1) { + var domain = username.split("@")[1]; + organizationName = domain.split(".")[0]; + + executeStep(2, { + authenticatorParams: { + local: { + OrganizationAuthenticator: { + org: organizationName + } + + } + }, + authenticationOptions: [{ + idp: "SSO" + }] + }, { + onSuccess: function(context) { + isDefault = false; + } + }); + } else { + executeStep(2, { + authenticationOptions: [{ + authenticator: 'BasicAuthenticator' + }] + }, {}); + } + } + }, + onFail: function(context) { + Log.info('User not found'); + var parameterMap = { + 'errorCode': 'login_failed', + 'errorMessage': 'login could not be completed', + "errorURI": 'https://localhost:9443/authenticationendpoint/login.jsp' + }; + fail(parameterMap); + + } + }); + } +}; diff --git a/server/business.js b/server/controllers/business.js similarity index 72% rename from server/business.js rename to server/controllers/business.js index dc62b18..66dedf5 100644 --- a/server/business.js +++ b/server/controllers/business.js @@ -17,8 +17,8 @@ */ import axios from "axios"; -import { getAccessToken, getOrganizationToken } from "./auth.js"; -import { agent, ASGARDEO_BASE_URL } from "./config.js"; +import { getAccessToken, getOrganizationToken } from "../middleware/auth.js"; +import { agent, ASGARDEO_BASE_URL } from "../config.js"; export async function isBusinessNameAvailable(businessName) { @@ -74,8 +74,8 @@ export async function getUserIdInOrganization(organizationId, username) { Accept: "application/json", }, params: { - filter: `userName eq ${username}`, - }, + filter: `userName eq ${username}`, + }, httpsAgent: agent, } ); @@ -99,8 +99,8 @@ export async function getAdminRoleIdInOrganization(organizationId) { Accept: "application/json", }, params: { - filter: `displayName eq Business Administrator`, - }, + filter: `displayName eq Business Administrator`, + }, httpsAgent: agent, } ); @@ -139,3 +139,44 @@ export async function addUserToAdminRole(organizationId, roleId, userId) { ); return response.data; } + +export async function getOrganizationId(organizationName) { + + const token = await getAccessToken(); + const response = await axios.get( + `${ASGARDEO_BASE_URL}/api/server/v1/organizations`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + params: { + filter: `name eq ${organizationName}`, + }, + httpsAgent: agent, // Attach the custom agents + } + ); + const organizations = response.data.organizations || []; + if (organizations.length === 0) { + throw new Error("Business not found."); + } + return organizations[0].id; +} + +export async function deleteOrganization(organizationId) { + + const token = await getAccessToken(); + const response = await axios.delete( + `${ASGARDEO_BASE_URL}/api/server/v1/organizations/${organizationId}`, + { + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + httpsAgent: agent, // Attach the custom agents + } + ); + return response.status; +} diff --git a/server/auth.js b/server/middleware/auth.js similarity index 96% rename from server/auth.js rename to server/middleware/auth.js index cf9fee2..88a8ef6 100644 --- a/server/auth.js +++ b/server/middleware/auth.js @@ -17,7 +17,7 @@ */ import axios from "axios"; -import { agent, CLIENT_ID, CLIENT_SECRET, TOKEN_ENDPOINT } from "./config.js"; +import { agent, CLIENT_ID, CLIENT_SECRET, TOKEN_ENDPOINT } from "../config.js"; // In-memory storage for token data let tokenData = { @@ -31,7 +31,7 @@ let tokenData = { // In-memory storage for organization token data let orgTokenCache = {}; -const getAuthHeader = () => { +export const getAuthHeader = () => { const authString = `${CLIENT_ID}:${CLIENT_SECRET}`; return "Basic " + Buffer.from(authString).toString("base64"); }; @@ -94,7 +94,7 @@ export const getAccessToken = async () => { export const getOrganizationToken = async (switchingOrganizationId) => { - // TODO: Consdier periodic expired token cleanup + // TODO: Consider periodic expired token cleanup: clean token after the sign up const currentTime = Math.floor(Date.now() / 1000); if ( orgTokenCache[switchingOrganizationId] && @@ -121,7 +121,7 @@ export const getOrganizationToken = async (switchingOrganizationId) => { params.append("switching_organization", switchingOrganizationId); params.append( "scope", - "internal_org_role_mgt_view internal_org_role_mgt_update internal_org_user_mgt_create internal_org_user_mgt_list internal_org_user_mgt_view" + "internal_org_role_mgt_view internal_org_role_mgt_update internal_org_user_mgt_create internal_org_user_mgt_list internal_org_user_mgt_view internal_oauth2_introspect" ); const response = await axios.post( diff --git a/server/server.js b/server/server.js index ebbfc6b..62d0dd4 100644 --- a/server/server.js +++ b/server/server.js @@ -21,9 +21,9 @@ import cors from "cors"; import axios from "axios"; import pino from "pino"; -import { getAccessToken, requireBearer } from "./auth.js"; -import { addUserToAdminRole, createOrganization, getAdminRoleIdInOrganization, getUserIdInOrganization, isBusinessNameAvailable } from "./business.js" -import { agent, ASGARDEO_BASE_URL_SCIM2, GEO_API_KEY, HOST, PORT, USER_STORE_NAME, VITE_REACT_APP_CLIENT_BASE_URL } from "./config.js"; +import { getAccessToken, requireBearer } from "./middleware/auth.js"; +import { addUserToAdminRole, createOrganization, deleteOrganization, getAdminRoleIdInOrganization, getOrganizationId, getUserIdInOrganization, isBusinessNameAvailable } from "./controllers/business.js" +import { agent, ASGARDEO_BASE_URL, ASGARDEO_BASE_URL_SCIM2, GEO_API_KEY, HOST, PORT, USER_STORE_NAME, VITE_REACT_APP_CLIENT_BASE_URL } from "./config.js"; const corsOptions = { origin: [VITE_REACT_APP_CLIENT_BASE_URL], @@ -191,45 +191,150 @@ app.post("/risk", async (req, res) => { } }); +async function deleteUser(req) { + + const token = await getAccessToken(); + const userAccessToken = req.token; + + const me = await axios.get(`${ASGARDEO_BASE_URL_SCIM2}/Me`, { + headers: { + Authorization: `Bearer ${userAccessToken}`, + Accept: "application/scim+json" + }, + httpsAgent: agent + }); + + const scimId = me.data?.id; + if (!scimId) { + return res.status(500).json({ error: "Could not resolve SCIM user id" }); + } + + const response = await axios.delete( + `${ASGARDEO_BASE_URL_SCIM2}/Users/${scimId}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: "*/*", + }, + httpsAgent: agent, // Attach the custom agent + } + ); + return response; +} + app.delete("/close-account", requireBearer, async (req, res) => { try { - const token = await getAccessToken(); - const userAccessToken = req.token; + const response = deleteUser(req); + if (response.status == 204) { + res.json({ + message: "Account removed successfully", + data: response.data, + }); + } + } catch (error) { + console.log("SCIM2 API Error:", error.detail || error.message); + res + .status(400) + .json({ error: error.detail || "An error occurred while deleting user" }); + } +}); - const me = await axios.get(`${ASGARDEO_BASE_URL_SCIM2}/Me`, { +app.delete("/close-business-account", requireBearer, async (req, res) => { + + try { + const organizationName = req.query.businessName; + const orgId = await getOrganizationId(organizationName); + const businessDeletionStatus = await deleteOrganization(orgId); + const deletionResponse = await deleteUser(req); + if (businessDeletionStatus == 204 && deletionResponse.status == 204) { + res.json({ + message: "Business account removed successfully" + }); + } + } catch (error) { + console.log("Error:", error.detail || error.message); + res + .status(400) + .json({ error: error.detail || "An error occurred while deleting business user" }); + } +}); + +app.get("/business", async (req, res) => { + + try { + const organizationId = req.query.organizationId; + const token = await getAccessToken(); + const response = await axios.get( + `${ASGARDEO_BASE_URL}/api/server/v1/organizations/${organizationId}`, + { headers: { - Authorization: `Bearer ${userAccessToken}`, - Accept: "application/scim+json" + Authorization: `Bearer ${token}`, + Accept: "application/json", }, - httpsAgent: agent + httpsAgent: agent, + } + ); + + const businessRegistrationAttribute = response.data.attributes.find(attr => attr.key === "business-registration-number"); + const businessRegNumber = businessRegistrationAttribute ? businessRegistrationAttribute.value : null; + + if (response.status === 200) { + res.json({ + "businessRegistrationNumber": businessRegNumber }); + } + } catch (error) { + console.log("Business API Error:", error.detail || error.message); + res + .status(400) + .json({ error: error.detail || "An error occurred while fetching business details" }); + } +}); - const scimId = me.data?.id; - if (!scimId) { - return res.status(500).json({ error: "Could not resolve SCIM user id" }); +app.patch("/business-update", async (req, res) => { + try { + const organizationId = req.body.organizationId; + const newBusinessRegistrationNumber = req.body.businessRegistrationNumber; + const operation = req.body.operation + + if (!organizationId || !newBusinessRegistrationNumber) { + return res.status(400).json({ error: "Missing organizationId or business details in request" }); } - const response = await axios.delete( - `${ASGARDEO_BASE_URL_SCIM2}/Users/${scimId}`, + const token = await getAccessToken(); + + const response = await axios.patch( + `${ASGARDEO_BASE_URL}/api/server/v1/organizations/${organizationId}`, + [ + { + operation, + path: "/attributes/business-registration-numberr", + value: newBusinessRegistrationNumber + } + ], { headers: { - Authorization: `Bearer ${token}`, - Accept: "*/*", + "Content-Type": "application/json", + Accept: "application/json", + Authorization: `Bearer ${token}` }, - httpsAgent: agent, // Attach the custom agent + httpsAgent: agent } ); - if (response.status == 204) { + + if (response.status === 200) { res.json({ - message: "Account removed successfully", - data: response.data, + message: "Business details updated successfully", + data: response.data }); + } else { + res.status(response.status).json({ error: "Failed to update business details" }); } } catch (error) { - console.log("SCIM2 API Error:", error.detail || error.message); - res - .status(400) - .json({ error: error.detail || "An error occurred while deleting user" }); + console.error("Organization PATCH API Error:", error.response?.data || error.message); + res.status(400).json({ + error: error.response?.data || "An error occurred while updating the business" + }); } });