From 089e0c4f726f90eefb9dd3a1039d30d6631ccb42 Mon Sep 17 00:00:00 2001 From: vishwas1 Date: Mon, 24 Aug 2026 00:17:32 +0530 Subject: [PATCH 1/2] implemented UI. to support Juridiction restriction --- Changelog.md | 3 + src/views/playground/WidgetConfig/Index.vue | 323 ++++++++++++++++++++ 2 files changed, 326 insertions(+) diff --git a/Changelog.md b/Changelog.md index 5836fdd..8cfd872 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,6 +1,9 @@ # Changelog ## [Unreleased] +### Added +- Added jurisdictional restrictions configuration to the ID widget config, including Blocklist/Allowlist strategy selection, searchable ISO alpha-3 country selection, backend payload persistence through `jurisdictionRules`, and hard-reject defaults. + ### [3.14.0] - 2026-08-07 ### Added - Added a toggle for mobile-assisted verification. diff --git a/src/views/playground/WidgetConfig/Index.vue b/src/views/playground/WidgetConfig/Index.vue index b62b509..d4fa86b 100644 --- a/src/views/playground/WidgetConfig/Index.vue +++ b/src/views/playground/WidgetConfig/Index.vue @@ -131,6 +131,93 @@ ul { padding: 6px 8px; } +.jurisdiction-panel { + background: #fff; +} + +.jurisdiction-description { + color: #6c757d; + font-size: 80%; + line-height: 1.45; + margin: 4px 0 22px; +} + +.jurisdiction-label { + color: #374151; + font-weight: 700; + margin-bottom: 10px; +} + +.strategy-card { + align-items: flex-start; + border: 1px solid #e5e7eb; + border-radius: 8px; + cursor: pointer; + display: flex; + height: 120px; + padding: 16px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.strategy-card.active { + border-color: #8ab9ff; + box-shadow: 0 0 0 1px #8ab9ff; +} + +.strategy-radio { + margin-right: 14px; + margin-top: 2px; +} + +.strategy-title { + color: #374151; + font-weight: 700; + margin-bottom: 18px; +} + +.strategy-help { + color: #6b7280; + font-size: 0.92rem; + line-height: 1.35; +} + +.country-chip-row { + display: flex; + flex-wrap: nowrap; + gap: 12px; + margin-bottom: 12px; + min-height: 58px; + overflow-x: auto; + padding-bottom: 6px; + width: 100%; +} + +.country-chip { + align-items: center; + border: 1px solid #e5e7eb; + border-radius: 8px; + color: #374151; + display: inline-flex; + flex: 0 0 auto; + font-weight: 600; + min-height: 44px; + padding: 10px 12px; +} + +.country-chip-flag { + font-size: 1.45rem; + line-height: 1; + margin-right: 10px; +} + +.country-chip-remove { + color: #374151; + cursor: pointer; + font-size: 1.2rem; + line-height: 1; + margin-left: 18px; +} + /* .zkbadge { background-color: lightblue; margin-left: 3px; @@ -372,6 +459,97 @@ ul { + +
  • +
    + + {{ widgetConfigUI.jurisdictionRules.label }} + +

    + {{ widgetConfigUI.jurisdictionRules.description }} +
    + {{ widgetConfigUI.jurisdictionRules.secondaryDescription }} +

    + +
    +
    +
    Enforcement Strategy
    +
    +
    +
    + +
    +
    {{ option.text }}
    +
    {{ option.description }}
    +
    +
    +
    +
    +
    + +
    +
    + Target Countries ({{ selectedJurisdictionCountries.length }}) +
    +
    + + {{ country.flag }} + {{ country.text }} ({{ country.value }}) + + × + + +
    + + + + + +
    +
    +
  • @@ -421,6 +599,10 @@ import { isAccessDeniedError } from '../../../utils/accessDenied'; import MarketplaceList from '../../../components/MarketplaceList.vue'; import HFBeta from '../../../components/element/HFBeta.vue'; import config from '../../../config'; +import countries from 'i18n-iso-countries'; +import enLocale from 'i18n-iso-countries/langs/en.json'; + +countries.registerLocale(enLocale); export default { name: "WidgetConfig", @@ -495,6 +677,21 @@ export default { }, immediate: true + }, + 'widgetConfigTemp.jurisdictionRules.enabled': { + handler(enabled) { + if (!enabled && this.widgetConfigTemp.jurisdictionRules) { + this.widgetConfigTemp.jurisdictionRules.countries = [] + this.widgetConfigTemp.jurisdictionRules.actionOnRestriction = 'HARD_REJECT' + } + } + }, + 'widgetConfigTemp.jurisdictionRules.strategy': { + handler(newStrategy, oldStrategy) { + if (oldStrategy && newStrategy !== oldStrategy && this.widgetConfigTemp.jurisdictionRules) { + this.widgetConfigTemp.jurisdictionRules.countries = [] + } + } } }, computed: { @@ -523,6 +720,34 @@ export default { }); return options }, + countryOptions() { + return Object.entries(countries.getNames('en', { select: 'alias' })) + .map(([alpha2, name]) => { + const alpha3 = countries.alpha2ToAlpha3(alpha2) + const displayName = alpha2 === 'AE' ? 'United Arab Emirates' : name + + return { + value: alpha3, + text: displayName, + displayText: `${displayName} (${alpha3})`, + flag: this.countryFlagFromAlpha2(alpha2), + } + }) + .filter(country => !!country.value) + .sort((a, b) => a.text.localeCompare(b.text)) + }, + selectedJurisdictionCountries() { + const selected = this.widgetConfigTemp.jurisdictionRules?.countries || [] + return selected.map(code => { + const option = this.countryOptions.find(country => country.value === code) + return option || { + value: code, + text: code, + displayText: code, + flag: this.countryFlagFromAlpha3(code), + } + }) + }, }, async mounted() { @@ -550,6 +775,7 @@ export default { if (typeof this.widgetConfigTemp.isMobileAssistedVerification !== 'boolean') { this.$set(this.widgetConfigTemp, 'isMobileAssistedVerification', true) } + this.ensureJurisdictionRules() this.trustedIssuersList = [...this.getMarketPlaceApps]; this.appId = this.$route.params.appId; @@ -659,6 +885,11 @@ export default { label: "Mobile-Assisted Verification", description: "When enabled, users who start verification on a desktop will continue ID verification on their mobile device by scanning a QR code. This improves verification success rates, provides a better user experience, and strengthens proof of possession. Disable to allow the entire verification process to be completed on the desktop." }, + jurisdictionRules: { + label: "Enable Jurisdictional Restrictions", + description: "Enforce compliance policies by restricting access based on document issuing country and nationality.", + secondaryDescription: "Choose to either block high-risk regions (Blocklist) or restrict onboarding to specific permitted territories (Allowlist)." + }, }, fullPage: true, isLoading: false, @@ -698,6 +929,12 @@ export default { isVaultEnabled: true, isEmailNotificationEnabled: true, isMobileAssistedVerification: true, + jurisdictionRules: { + enabled: false, + strategy: 'BLOCKLIST', + countries: [], + actionOnRestriction: 'HARD_REJECT', + }, issuerDID: "", issuerVerificationMethodId: "", }, @@ -726,6 +963,18 @@ export default { text: "Government ID" }, ], + jurisdictionStrategyOptions: [ + { + value: 'BLOCKLIST', + text: 'Blocklist', + description: 'Block selected countries', + }, + { + value: 'ALLOWLIST', + text: 'Allowlist', + description: 'Only permit selected countries', + }, + ], } }, @@ -742,6 +991,77 @@ export default { this.notifyErr(message) }, + defaultJurisdictionRules() { + return { + enabled: false, + strategy: 'BLOCKLIST', + countries: [], + actionOnRestriction: 'HARD_REJECT', + } + }, + ensureJurisdictionRules() { + const defaults = this.defaultJurisdictionRules() + + if (!this.widgetConfigTemp.jurisdictionRules) { + this.$set(this.widgetConfigTemp, 'jurisdictionRules', defaults) + return + } + + const existingRules = this.widgetConfigTemp.jurisdictionRules + Object.keys(defaults).forEach(key => { + if (!Object.prototype.hasOwnProperty.call(existingRules, key)) { + this.$set(existingRules, key, defaults[key]) + } + }) + + existingRules.enabled = existingRules.enabled === true + existingRules.strategy = ['BLOCKLIST', 'ALLOWLIST'].includes(existingRules.strategy) + ? existingRules.strategy + : defaults.strategy + existingRules.countries = Array.isArray(existingRules.countries) + ? existingRules.countries.map(country => String(country).toUpperCase()).filter(country => !!country) + : [] + existingRules.actionOnRestriction = 'HARD_REJECT' + }, + countryFlagFromAlpha2(alpha2) { + if (!alpha2 || alpha2.length !== 2) return '' + return alpha2 + .toUpperCase() + .replace(/./g, char => String.fromCodePoint(127397 + char.charCodeAt())) + }, + countryFlagFromAlpha3(alpha3) { + const alpha2 = countries.alpha3ToAlpha2(alpha3) + return this.countryFlagFromAlpha2(alpha2) + }, + removeJurisdictionCountry(countryCode) { + const selectedCountries = this.widgetConfigTemp.jurisdictionRules.countries || [] + this.widgetConfigTemp.jurisdictionRules.countries = selectedCountries.filter(country => country !== countryCode) + }, + validateJurisdictionRules() { + this.ensureJurisdictionRules() + const rules = this.widgetConfigTemp.jurisdictionRules + + if (!rules.enabled) { + rules.countries = [] + rules.actionOnRestriction = 'HARD_REJECT' + return + } + + if (!['BLOCKLIST', 'ALLOWLIST'].includes(rules.strategy)) { + throw new Error('Kindly select a jurisdiction enforcement strategy') + } + + if (!Array.isArray(rules.countries) || rules.countries.length === 0) { + throw new Error('Kindly select at least one target country for jurisdiction restrictions') + } + + const invalidCountries = rules.countries.filter(country => !/^[A-Z]{3}$/.test(country) || !countries.alpha3ToAlpha2(country)) + if (invalidCountries.length > 0) { + throw new Error(`Invalid country code(s): ${invalidCountries.join(', ')}`) + } + + rules.actionOnRestriction = 'HARD_REJECT' + }, selectedServiceEventHandler(event) { // Guard: ignore entries with no issuerDid @@ -865,6 +1185,7 @@ export default { this.widgetConfigTemp.isWidgetLogin = this.widgetConfigTemp.isWidgetLogin !== false this.widgetConfigTemp.isMobileAssistedVerification = this.widgetConfigTemp.isMobileAssistedVerification !== false this.migratedocumentUploadMode() + this.validateJurisdictionRules() if (!this.widgetConfigTemp.issuerDID) { throw new Error('Issuer DID is required') } @@ -924,6 +1245,7 @@ export default { await this.createAppsWidgetConfig() if (this.widgetConfig) { this.widgetConfigTemp = JSON.parse(JSON.stringify(this.widgetConfig)) + this.ensureJurisdictionRules() } this.isLoading = false @@ -947,6 +1269,7 @@ export default { if (this.widgetConfig) { // this.widgetConfigTemp = { ...this.widgetConfig } this.widgetConfigTemp.trustedIssuer = this.widgetConfigTemp.issuerDID ? true : false; + this.ensureJurisdictionRules() } this.isLoading = false } catch (e) { From ffa8363d8c00da76d6ee06cbebc7a8a65b09131a Mon Sep 17 00:00:00 2001 From: Pratap Mridha Date: Mon, 24 Aug 2026 16:59:16 +0530 Subject: [PATCH 2/2] Adr008 (#214) * commented risk flagged iscon * fixed UI * integrated decion api * implemetned action drop adown as well * ui updates * update ui * update --------- Co-authored-by: vishwas1 --- src/config.js | 9 +- src/mixins/utils.js | 14 + src/store/mainStore.js | 42 ++ src/views/playground/SessionsDetails.vue | 749 +++++++++++++++++++- src/views/playground/WidgetConfig/Index.vue | 280 ++++++-- 5 files changed, 1017 insertions(+), 77 deletions(-) diff --git a/src/config.js b/src/config.js index 599907f..c718d21 100644 --- a/src/config.js +++ b/src/config.js @@ -177,8 +177,13 @@ config['FaicalAuthenticationError'] = { 4: 'Rejected, to perform face check due to the pose of the face', 5: 'Rejected, due to problems in the extraction of the facial pattern', 6: 'Rejected, because document has already been verified in some other account with this service', - 7: 'Failed, because the date of birth could not be read correctly from the document' + 7: 'Failed, because the date of birth could not be read correctly from the document', + 8: 'Failed, because document issuing country is restricted', + 9: 'Failed, because nationality is restricted', + 10: 'Failed, because document issuing country is not allowed', + 11: 'Failed, because nationality is not allowed', + 12: 'Failed, because jurisdiction restriction requires manual review' } const kycServerBaseUrl = process.env.VUE_APP_KYC_SERVER_BASE_URL || 'https://api.cavach.hypersign.id' config['KYC_SERVER_BASE_URL'] = kycServerBaseUrl; -export default config \ No newline at end of file +export default config diff --git a/src/mixins/utils.js b/src/mixins/utils.js index 5cc5fad..0121e0f 100644 --- a/src/mixins/utils.js +++ b/src/mixins/utils.js @@ -203,6 +203,20 @@ export default { ` } + if (status == 'REJECTED') { + return ` + + Rejected +` + } + + if (status == 'MANUAL_REVIEW') { + return ` + + Manual Review +` + } + if (status == 'Expired') { return ` diff --git a/src/store/mainStore.js b/src/store/mainStore.js index 18151c7..b66333e 100644 --- a/src/store/mainStore.js +++ b/src/store/mainStore.js @@ -2396,6 +2396,48 @@ const mainStore = { }) }) }, + submitManualReviewDecision: async ({ getters, dispatch }, payload) => { + const { sessionId, action, reasonCode, comments } = payload + if (!sessionId) { + throw new Error('Session Id is required') + } + if (!action) { + throw new Error('Decision action is required') + } + if (!getters.getSelectedService || !getters.getSelectedService.tenantUrl) { + throw new Error('Tenant url is null or empty, service is not selected') + } + if (!getters.getSelectedService.access_token) { + throw new Error('authToken is invalid, service is not selected') + } + + const token = await dispatch('getValidToken', { + serviceId: getters.getSelectedService.appId, + grant_type: config.GRANT_TYPES_ENUM.CAVACH_API, + tokenStorageKey: "access_token" + }); + const headers = UtilsMixin.methods.getKycServiceHeader(token); + const url = `${sanitizeUrl(getters.getSelectedService.tenantUrl)}/api/v1/e-kyc/verification/sessions/${encodeURIComponent(sessionId)}/decision`; + const resp = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify({ + action, + reasonCode, + comments + }) + }) + const json = await resp.json() + if (!resp.ok || json.error) { + const errorDetails = json.error?.details; + const message = Array.isArray(errorDetails) + ? errorDetails.join(' ') + : json.error?.message || json.error || json.message || 'Error while submitting manual review decision' + throw new Error(message) + } + + return json.data || json + }, async fetchUsageForASSIService({ getters, dispatch }, payload) { const { startDate, endDate } = payload if (!getters.getSelectedService || !getters.getSelectedService.tenantUrl) { diff --git a/src/views/playground/SessionsDetails.vue b/src/views/playground/SessionsDetails.vue index 5513e21..8beef64 100644 --- a/src/views/playground/SessionsDetails.vue +++ b/src/views/playground/SessionsDetails.vue @@ -30,6 +30,86 @@ font-size: 13px; } +.info-summary-row { + display: flex; + align-items: center; + gap: 24px; + width: 100%; + min-width: 0; +} + +.info-summary-item { + min-width: 0; +} + +.info-summary-date { + flex: 0 0 155px; +} + +.info-summary-email { + flex: 0 1 280px; +} + +.info-summary-attempts { + flex: 0 0 70px; +} + +.info-summary-status { + flex: 0 0 130px; + display: flex; + align-items: center; +} + +.info-actions-wrap { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 10px; + flex: 0 0 auto; + margin-left: auto; + white-space: nowrap; +} + +.manual-review-btn { + min-width: 210px; + border: 1px solid #f59e0b; + background: #fffaf0; + color: #b45309; + border-radius: 10px; + padding: 6px 12px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; + line-height: 1.1; + box-shadow: 0 1px 2px rgba(245, 158, 11, 0.08); +} + +.manual-review-btn:hover { + background: #fffbeb; + color: #92400e; +} + +.manual-review-btn .review-title { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 12px; +} + +.manual-review-btn .review-subtitle { + display: block; + margin-top: 3px; + color: #374151; + font-size: 10px; + font-weight: 600; + text-transform: none; +} + +.manual-review-btn i { + font-size: 12px; +} + .card-section-title { font-size: 13px; font-weight: 700; @@ -178,6 +258,104 @@ background: #fff; } +::v-deep(#manual-review-decision-modal .modal-content) { + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); + overflow: hidden; + font-size: 13px; +} + +::v-deep(#manual-review-decision-modal .modal-header) { + padding: 16px 20px; +} + +::v-deep(#manual-review-decision-modal .modal-title) { + font-size: 14px; + font-weight: 600; +} + +::v-deep(#manual-review-decision-modal .custom-control-label), +::v-deep(#manual-review-decision-modal .alert), +::v-deep(#manual-review-decision-modal .form-control), +::v-deep(#manual-review-decision-modal .btn) { + font-size: 13px; +} + +::v-deep(#manual-review-decision-modal .modal-body) { + background: #f9fafb; + padding: 24px; +} + +::v-deep(#manual-review-decision-modal .modal-footer) { + background: #fff; + border-top: 1px solid #e5e7eb; + padding: 12px 20px; +} + +.manual-review-cancel-btn { + background: #fff !important; + border-color: #6c757d !important; + color: #6c757d !important; +} + +.manual-review-cancel-btn:hover, +.manual-review-cancel-btn:focus { + background: #6c757d !important; + color: #fff !important; +} + +.manual-review-submit-btn { + background: #3b82f6 !important; + border-color: #3b82f6 !important; + color: #fff !important; +} + +.manual-review-submit-btn:hover, +.manual-review-submit-btn:focus { + background: #2563eb !important; + border-color: #2563eb !important; + color: #fff !important; +} + +.manual-review-submit-btn:disabled { + background: #3b82f6 !important; + border-color: #3b82f6 !important; + color: #fff !important; + opacity: 0.65; +} + +.risk-flag-list { + max-height: 460px; + overflow-y: auto; + padding-right: 4px; +} + +@media (max-width: 960px) { + .info-summary-row { + flex-wrap: wrap; + gap: 16px; + } + + .info-summary-date, + .info-summary-email, + .info-summary-attempts, + .info-summary-status, + .info-actions-wrap { + flex: 1 1 100%; + } + + .manual-review-btn { + width: 100%; + min-width: 0; + } + + .info-actions-wrap { + justify-content: stretch; + } + +} + .timeline-item { position: relative; padding: 8px 12px 8px 28px; @@ -244,8 +422,95 @@ .info-actions { display: flex; align-items: center; - justify-content: flex-end; - gap: 8px; + border-radius: 6px; + font-size: 12px; + justify-content: center; + gap: 6px; + min-height: 36px; + min-width: 80px; + padding: 6px 12px; +} + +.decision-history-wrap { + margin-left: 4px; + padding-left: 20px; + position: relative; +} + +.decision-history-wrap::before { + background: #e5e7eb; + bottom: 14px; + content: ""; + left: 5px; + position: absolute; + top: 12px; + width: 1px; +} + +.decision-history-item { + padding: 0 0 10px; + position: relative; +} + +.decision-history-item::before { + background: #fff; + border: 2px solid #9ca3af; + border-radius: 50%; + content: ""; + height: 10px; + left: -20px; + position: absolute; + top: 12px; + width: 10px; + z-index: 1; +} + +.decision-history-item:first-child::before { + background: #6b7280; + border-color: #6b7280; +} + +.decision-history-item:last-child { + padding-bottom: 0; +} + +.decision-history-event { + background: #f9fafb; + border: 1px solid #eef0f3; + border-radius: 6px; + padding: 9px 11px; +} + +.decision-history-event-header { + align-items: center; + border-bottom: 1px solid #e5e7eb; + display: flex; + justify-content: space-between; + padding-bottom: 7px; +} + +.decision-history-grid { + display: grid; + gap: 5px 10px; + grid-template-columns: 96px minmax(0, 1fr); + margin-top: 7px; +} + +.decision-history-label { + color: #6b7280; + font-size: 11px; + font-weight: 500; +} + +.decision-history-value { + color: #374151; + font-size: 12px; + font-weight: 500; + min-width: 0; + overflow: hidden; + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; } @@ -296,12 +561,12 @@ >
    -
    -
    +
    +
    Date
    {{ formatDate(session.createdAt) }}
    -
    +
    Email
    -
    +
    Attempts
    {{ session ? session.retryAttempts : "-" }}
    - +
    +
    @@ -508,6 +787,81 @@
    + + +
    +
    + Decision History +
    +
    +
    +
    +
    +
    + Status + + {{ decisionStatusLabel(decision.action) }} + +
    +
    + Decided By + + {{ decision.decidedBy || "-" }} + + + Time + + {{ formatDate(decision.decidedAt) }} + + + + + +
    +
    +
    +
    +
    +
    +
    + + + +

    + Review the risk flags below and decide to approve or reject this verification. +

    + + + + +
    + + + Review Required + + + {{ riskFlagCount }} risk {{ riskFlagCount === 1 ? "flag" : "flags" }} + +
    + +

    + This verification passed automated checks but requires manual review before it can be approved. +

    + +
    + +
    + {{ flag.title }} + + {{ formatSeverity(flag.severity) }} + +
    +

    {{ flag.description }}

    +
    + {{ flag.metaLine }} +
    +
    +
    +
    +
    + + + +
    Take decision
    + + + Approve + + + + Reject + + + + This verification will be approved and the user can proceed. + + + This verification will be rejected and the user will not be able to proceed. + + +
    + +
    Common reasons
    + + {{ reason.label }} + +
    + +
    + + +
    + {{ reviewComments.length }} / 300 +
    +
    +
    +
    +
    + + +
    @@ -885,7 +1382,12 @@ const FaicalAuthenticationError = { 4: "Failed to perform face check due to the pose of the face", 5: "Failed due to problems in the extraction of the facial pattern", 6: "Duplicate document was used", - 7: 'Failed, because the date of birth could not be read correctly from the document' + 7: 'Failed, because the date of birth could not be read correctly from the document', + 8: "Failed, because document issuing country is restricted", + 9: "Failed, because nationality is restricted", + 10: "Failed, because document issuing country is not allowed", + 11: "Failed, because nationality is not allowed", + 12: "Failed, because jurisdiction restriction requires manual review", }; const ZkpVerificationResultEnum = { @@ -1138,6 +1640,41 @@ export default { (a, b) => new Date(a.createdAt) - new Date(b.createdAt) ); }, + normalizedRiskFlags() { + const flags = this.session?.riskFlags || []; + if (!Array.isArray(flags)) return []; + + return flags.map((flag, index) => this.normalizeRiskFlag(flag, index)); + }, + hasRiskFlags() { + return this.normalizedRiskFlags.length > 0; + }, + riskFlagCount() { + return this.normalizedRiskFlags.length; + }, + normalizedManualDecisions() { + const decisions = this.session?.manualDecisions || []; + if (!Array.isArray(decisions)) return []; + + return decisions + .map((decision, index) => ({ + ...decision, + key: `${decision.action || "DECISION"}-${decision.decidedAt || index}`, + action: decision.action || "", + reasonCode: decision.reasonCode || "", + comments: decision.comments || "", + decidedBy: decision.decidedBy || "", + decidedAt: decision.decidedAt || "", + })) + .sort((a, b) => { + const aTime = Date.parse(a.decidedAt) || 0; + const bTime = Date.parse(b.decidedAt) || 0; + return bTime - aTime; + }); + }, + hasManualDecisions() { + return this.normalizedManualDecisions.length > 0; + }, }, data() { return { @@ -1153,6 +1690,19 @@ export default { sessionId: "", env: "prod", session: {}, + showManualReviewModal: false, + isSubmittingReviewDecision: false, + reviewDecision: "REJECTED", + reviewReasonCode: "", + reviewComments: "", + reviewReasonOptions: [ + { label: "Suspicious document", value: "SUSPICIOUS_DOCUMENT" }, + { label: "Identity could not be verified", value: "IDENTITY_NOT_VERIFIED" }, + { label: "Biometric mismatch", value: "BIOMETRIC_MISMATCH" }, + { label: "Restricted jurisdiction", value: "RESTRICTED_JURISDICTION" }, + { label: "Fraud / duplicate identity", value: "FRAUD_DUPLICATE_IDENTITY" }, + { label: "Other", value: "OTHER" }, + ], popupHeader: "", popupImage: "", deviceDetails: { @@ -1278,12 +1828,187 @@ export default { }); }, methods: { - ...mapActions("mainStore", ["fetchSessionsDetailsById"]), + ...mapActions("mainStore", ["fetchSessionsDetailsById", "submitManualReviewDecision"]), formatDate(date) { if (!date) return "-"; const d = new Date(date); return d.toLocaleString(); // Customize formatting if needed }, + openManualReviewModal() { + this.showManualReviewModal = true; + }, + closeManualReviewModal() { + this.showManualReviewModal = false; + }, + async submitReviewDecision() { + if (this.reviewDecision === "REJECTED" && !this.reviewReasonCode) { + this.notifyErr("Please select a reason for rejection."); + return; + } + + const decisionSessionId = this.session?.latestSessionId || this.session?.sessionId || this.sessionId; + const reasonCode = this.reviewDecision === "REJECTED" + ? this.reviewReasonCode + : "MANUAL_REVIEW_CLEARED"; + + try { + this.isSubmittingReviewDecision = true; + await this.submitManualReviewDecision({ + sessionId: decisionSessionId, + action: this.reviewDecision, + reasonCode, + comments: this.reviewComments, + }); + + this.session = await this.fetchSessionsDetailsById({ + sessionId: this.sessionId, + env: this.env, + businessId: this.isValidBusinessID ? this.companyId : undefined, + }) || {}; + + this.notifySuccess("Manual review decision submitted successfully."); + this.closeManualReviewModal(); + } catch (e) { + this.notifyErr(e.message || "Unable to submit manual review decision."); + } finally { + this.isSubmittingReviewDecision = false; + } + }, + normalizeRiskFlag(flag, index) { + const code = flag?.code || flag?.reason || "RISK_FLAG"; + const severity = flag?.severity || "MEDIUM"; + const metadata = flag?.metadata || {}; + const metaLine = this.getRiskMetaLine(code, metadata); + + return { + ...flag, + key: `${code}-${index}`, + code, + severity, + source: flag?.source || flag?.associatedStep || flag?.step || "", + associatedStep: flag?.associatedStep || flag?.step || "", + title: this.formatRiskCode(code), + description: flag?.message || this.getRiskFlagDescription(code), + metadata, + metaLine, + }; + }, + formatRiskCode(code) { + const labels = { + JURISDICTION_RESTRICTION_MATCH: "Jurisdiction restriction", + RESTRICTED_NATIONALITY: "Jurisdiction restriction", + RESTRICTED_ISSUING_COUNTRY: "Jurisdiction restriction", + LOW_OCR_CONFIDENCE: "Low OCR confidence", + BIOMETRIC_MATCH_BELOW_THRESHOLD: "Biometric match below threshold", + BORDERLINE_LIVENESS: "Borderline liveness", + FACIAL_SPOOF_SUSPECTED: "Facial spoof suspected", + DUPLICATE_FACE_DETECTED: "Duplicate face detected", + DOCUMENT_EXPIRING_SOON: "Document expiring soon", + NATIONALITY_MISMATCH: "Nationality mismatch", + ZKP_PROOF_WARNING: "Age verification warning", + }; + + if (labels[code]) return labels[code]; + return String(code) + .toLowerCase() + .split("_") + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + }, + getRiskFlagDescription(code) { + const descriptions = { + JURISDICTION_RESTRICTION_MATCH: + "Document issuing country or nationality matches a configured manual review policy.", + RESTRICTED_NATIONALITY: + "Document nationality matches a configured manual review policy.", + RESTRICTED_ISSUING_COUNTRY: + "Document issuing country matches a configured manual review policy.", + LOW_OCR_CONFIDENCE: + "Document field confidence is below the configured threshold.", + BIOMETRIC_MATCH_BELOW_THRESHOLD: + "Face similarity score is below the auto-approval threshold.", + BORDERLINE_LIVENESS: + "Liveness confidence is inconclusive and requires manual inspection.", + FACIAL_SPOOF_SUSPECTED: + "Potential presentation attack was detected with soft certainty.", + DUPLICATE_FACE_DETECTED: + "A matching face or document may already exist for another user.", + DOCUMENT_EXPIRING_SOON: + "The identity document is valid but close to expiry.", + NATIONALITY_MISMATCH: + "Document issuing country differs from declared nationality.", + ZKP_PROOF_WARNING: + "Age verification produced a non-fatal warning.", + }; + + return descriptions[code] || "This verification triggered a risk flag that requires review."; + }, + getRiskMetaLine(code, metadata) { + const country = metadata.matchedCountry || metadata.evaluatedNationality || metadata.evaluatedIssuingCountry; + const field = metadata.matchedField; + const score = metadata.score || metadata.faceSimilarity || metadata.faceSimilarityScore; + + if (score) { + const numericScore = Number(String(score).replace("%", "")); + if (!Number.isNaN(numericScore)) { + return `Face similarity: ${numericScore > 1 ? numericScore : Math.round(numericScore * 1000) / 10}%`; + } + return `Face similarity: ${score}`; + } + if (country && field) return `${this.formatRiskCode(field)}: ${country}`; + if (country) return `Matched country: ${country}`; + if (metadata.threshold) return `Threshold: ${metadata.threshold}`; + return ""; + }, + formatSeverity(severity) { + return String(severity || "MEDIUM").toLowerCase().replace(/^./, (char) => char.toUpperCase()); + }, + riskSeverityClass(severity) { + const value = String(severity || "MEDIUM").toLowerCase(); + return { + "badge-outline-danger": value === "high", + "badge-outline-warning": value === "medium", + "badge-outline-secondary": value === "low", + }; + }, + riskFlagCardClass(severity) { + const value = String(severity || "MEDIUM").toLowerCase(); + return { + "border-danger": value === "high", + "border-warning": value === "medium", + "border-secondary": value === "low", + }; + }, + riskFlagIconClass(severity) { + const value = String(severity || "MEDIUM").toLowerCase(); + return value === "high" ? "fa fa-exclamation-circle" : "fa fa-exclamation-triangle"; + }, + decisionStatusLabel(action) { + const labels = { + MANUAL_REVIEW: "Manual Review", + APPROVED: "Approved", + Success: "Approved", + REJECTED: "Rejected", + }; + + if (labels[action]) return labels[action]; + return this.formatRiskCode(action || "Decision"); + }, + decisionStatusClass(action) { + if (action === "REJECTED") return "badge-outline-danger"; + if (action === "APPROVED" || action === "Success") return "badge-outline-success"; + return "badge-outline-warning"; + }, + formatDecisionReason(reasonCode) { + const reason = this.reviewReasonOptions.find((item) => item.value === reasonCode); + if (reason) return reason.label; + + return this.formatRiskCode(reasonCode); + }, + isManualReviewCleared(decision) { + return decision?.reasonCode === "MANUAL_REVIEW_CLEARED"; + }, applySessionFailureToTimeline() { if (this.session?.status !== "Failed") return; diff --git a/src/views/playground/WidgetConfig/Index.vue b/src/views/playground/WidgetConfig/Index.vue index d4fa86b..01cc544 100644 --- a/src/views/playground/WidgetConfig/Index.vue +++ b/src/views/playground/WidgetConfig/Index.vue @@ -133,6 +133,7 @@ ul { .jurisdiction-panel { background: #fff; + min-width: 0; } .jurisdiction-description { @@ -148,15 +149,33 @@ ul { margin-bottom: 10px; } +.jurisdiction-config-grid { + display: grid; + gap: 24px; + grid-template-columns: minmax(300px, 5fr) minmax(0, 7fr); +} + +.jurisdiction-column { + min-width: 0; +} + +.strategy-options { + display: grid; + gap: 12px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + .strategy-card { align-items: flex-start; border: 1px solid #e5e7eb; border-radius: 8px; cursor: pointer; display: flex; - height: 120px; + margin: 0; + min-height: 120px; padding: 16px; transition: border-color 0.15s ease, box-shadow 0.15s ease; + width: 100%; } .strategy-card.active { @@ -165,6 +184,7 @@ ul { } .strategy-radio { + flex: 0 0 auto; margin-right: 14px; margin-top: 2px; } @@ -188,7 +208,8 @@ ul { margin-bottom: 12px; min-height: 58px; overflow-x: auto; - padding-bottom: 6px; + padding: 1px 0 8px; + scrollbar-width: thin; width: 100%; } @@ -218,6 +239,108 @@ ul { margin-left: 18px; } +.country-dropdown-item { + align-items: center; + display: flex; + justify-content: space-between; + width: 100%; +} + +.jurisdiction-country-search-wrap { + position: relative; +} + +.jurisdiction-country-search-hint { + color: #7c8798; + font-size: 10px; + left: 44px; + line-height: 1; + pointer-events: none; + position: absolute; + top: 7px; + z-index: 2; +} + +::v-deep(.jurisdiction-country-select.v-text-field--outlined > .v-input__control > .v-input__slot) { + background: #f8fafc; + border-radius: 8px; + min-height: 44px; + transition: background-color 0.15s ease, box-shadow 0.15s ease; +} + +::v-deep(.jurisdiction-country-select.v-text-field--outlined:not(.v-input--is-focused) fieldset) { + border-color: #e2e8f0; +} + +::v-deep(.jurisdiction-country-select.v-text-field--outlined.v-input--is-focused fieldset) { + border-color: #94a3b8; + border-width: 1px; +} + +::v-deep(.jurisdiction-country-select.v-input--is-focused > .v-input__control > .v-input__slot) { + background: #fff; + box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.14); +} + +::v-deep(.jurisdiction-country-select:not(.v-input--is-focused):hover fieldset) { + border-color: #cbd5e1; +} + +::v-deep(.jurisdiction-country-select .v-input__prepend-inner), +::v-deep(.jurisdiction-country-select .v-input__append-inner) { + align-self: center; + margin-top: 0; +} + +::v-deep(.jurisdiction-country-select .v-input__prepend-inner) { + padding-left: 2px; + padding-right: 10px; +} + +::v-deep(.jurisdiction-country-select input) { + color: #374151; + font-size: 13px; + line-height: 20px; + margin-top: 11px; + max-height: 32px; +} + +::v-deep(.jurisdiction-country-select .v-icon) { + color: #7c8798 !important; +} + +.jurisdiction-action-column { + grid-column: 1 / -1; + max-width: 440px; +} + +.jurisdiction-action-help { + color: #6b7280; + font-size: 0.86rem; + line-height: 1.4; + margin-top: 8px; +} + +@media (max-width: 991.98px) { + .jurisdiction-config-grid { + grid-template-columns: 1fr; + } + + .jurisdiction-action-column { + grid-column: auto; + } +} + +@media (max-width: 575.98px) { + .strategy-options { + grid-template-columns: 1fr; + } + + .strategy-card { + min-height: auto; + } +} + /* .zkbadge { background-color: lightblue; margin-left: 3px; @@ -475,36 +598,32 @@ ul { {{ widgetConfigUI.jurisdictionRules.secondaryDescription }}

    -
    -
    +
    +
    Enforcement Strategy
    -
    -
    +
    -
    +
    Target Countries ({{ selectedJurisdictionCountries.length }})
    @@ -525,28 +644,54 @@ ul {
    - - - - - +
    + + Select or search country... + + + + + + +
    +
    + +
    +
    Action on Match
    + +
    + {{ selectedJurisdictionActionDescription }} +
    @@ -678,21 +823,6 @@ export default { immediate: true }, - 'widgetConfigTemp.jurisdictionRules.enabled': { - handler(enabled) { - if (!enabled && this.widgetConfigTemp.jurisdictionRules) { - this.widgetConfigTemp.jurisdictionRules.countries = [] - this.widgetConfigTemp.jurisdictionRules.actionOnRestriction = 'HARD_REJECT' - } - } - }, - 'widgetConfigTemp.jurisdictionRules.strategy': { - handler(newStrategy, oldStrategy) { - if (oldStrategy && newStrategy !== oldStrategy && this.widgetConfigTemp.jurisdictionRules) { - this.widgetConfigTemp.jurisdictionRules.countries = [] - } - } - } }, computed: { ...mapState({ @@ -748,6 +878,11 @@ export default { } }) }, + selectedJurisdictionActionDescription() { + const action = this.widgetConfigTemp.jurisdictionRules?.actionOnRestriction + const option = this.jurisdictionActionOptions.find(option => option.value === action) + return option?.description || '' + }, }, async mounted() { @@ -975,6 +1110,18 @@ export default { description: 'Only permit selected countries', }, ], + jurisdictionActionOptions: [ + { + value: 'HARD_REJECT', + text: 'Hard Reject (Stop Onboarding)', + description: 'Rejects the session immediately if the user matches the configured jurisdiction rule.', + }, + { + value: 'MANUAL_REVIEW', + text: 'Manual Review', + description: 'Allows the user to complete verification, then sends the session to the manual review queue.', + }, + ], } }, @@ -1021,7 +1168,9 @@ export default { existingRules.countries = Array.isArray(existingRules.countries) ? existingRules.countries.map(country => String(country).toUpperCase()).filter(country => !!country) : [] - existingRules.actionOnRestriction = 'HARD_REJECT' + existingRules.actionOnRestriction = ['HARD_REJECT', 'MANUAL_REVIEW'].includes(existingRules.actionOnRestriction) + ? existingRules.actionOnRestriction + : defaults.actionOnRestriction }, countryFlagFromAlpha2(alpha2) { if (!alpha2 || alpha2.length !== 2) return '' @@ -1037,13 +1186,16 @@ export default { const selectedCountries = this.widgetConfigTemp.jurisdictionRules.countries || [] this.widgetConfigTemp.jurisdictionRules.countries = selectedCountries.filter(country => country !== countryCode) }, + isJurisdictionCountrySelected(countryCode) { + return (this.widgetConfigTemp.jurisdictionRules.countries || []).includes(countryCode) + }, validateJurisdictionRules() { this.ensureJurisdictionRules() const rules = this.widgetConfigTemp.jurisdictionRules if (!rules.enabled) { rules.countries = [] - rules.actionOnRestriction = 'HARD_REJECT' + rules.actionOnRestriction = rules.actionOnRestriction || 'HARD_REJECT' return } @@ -1060,7 +1212,9 @@ export default { throw new Error(`Invalid country code(s): ${invalidCountries.join(', ')}`) } - rules.actionOnRestriction = 'HARD_REJECT' + if (!['HARD_REJECT', 'MANUAL_REVIEW'].includes(rules.actionOnRestriction)) { + throw new Error('Kindly select a jurisdiction action on match') + } }, selectedServiceEventHandler(event) {