From 463fad816480021b6ee599a7aaaff9aa66ec72e8 Mon Sep 17 00:00:00 2001 From: usmanimamu17-create Date: Thu, 27 Aug 2026 08:27:48 +0100 Subject: [PATCH] feat(ci): add dependency license check and attributions --- .github/workflows/audit.yml | 7 +- app/licenses.tsx | 20 ++ assets/THIRD_PARTY_NOTICES.json | 336 +++++++++++++++++++++++ license-allowlist.json | 19 ++ license-exceptions.json | 4 + package.json | 3 + scripts/README.md | 43 +++ scripts/license-audit.js | 147 ++++++++++ src/audit/analyzers/NetworkAnalyzer.ts | 81 ++++-- src/components/mobile/LicensesScreen.tsx | 68 +++++ src/components/mobile/MobileSettings.tsx | 9 + 11 files changed, 709 insertions(+), 28 deletions(-) create mode 100644 app/licenses.tsx create mode 100644 assets/THIRD_PARTY_NOTICES.json create mode 100644 license-allowlist.json create mode 100644 license-exceptions.json create mode 100644 scripts/license-audit.js create mode 100644 src/components/mobile/LicensesScreen.tsx diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 54436687..153da676 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -14,7 +14,7 @@ permissions: jobs: audit: - name: npm audit + depcheck + name: npm audit + depcheck + license runs-on: ubuntu-latest permissions: contents: read @@ -42,4 +42,7 @@ jobs: run: npm run audit - name: Check for unused dependencies - run: npm run depcheck \ No newline at end of file + run: npm run depcheck + + - name: Check for disallowed dependency licenses + run: npm run license:check \ No newline at end of file diff --git a/app/licenses.tsx b/app/licenses.tsx new file mode 100644 index 00000000..7ef8ad9b --- /dev/null +++ b/app/licenses.tsx @@ -0,0 +1,20 @@ +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { SettingsSkeleton } from '@/components/mobile/SettingsSkeleton'; +import { createLazyRoute } from '@/utils/lazyRoute'; + +const LazyLicenses = createLazyRoute({ + importFn: () => import('@/components/mobile/LicensesScreen'), + LoadingFallback: SettingsSkeleton, + boundaryName: 'LicensesRoute', +}); + +const LicensesScreen = () => { + return ( + + + + ); +}; + +export default LicensesScreen; diff --git a/assets/THIRD_PARTY_NOTICES.json b/assets/THIRD_PARTY_NOTICES.json new file mode 100644 index 00000000..f40cb6e2 --- /dev/null +++ b/assets/THIRD_PARTY_NOTICES.json @@ -0,0 +1,336 @@ +{ + "generatedAt": "2026-08-27T07:23:45.610Z", + "note": "Baseline snapshot from package.json direct production dependencies. Run \"npm run attribution\" (scripts/license-audit.js --generate-attribution) to regenerate the full transitive tree with resolved licenses.", + "packages": [ + { + "name": "@expo/config-plugins", + "version": "56.0.9", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@expo/vector-icons", + "version": "15.0.3", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-native-async-storage/async-storage", + "version": "2.2.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-native-community/netinfo", + "version": "12.0.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-navigation/bottom-tabs", + "version": "7.4.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-navigation/drawer", + "version": "7.5.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-navigation/elements", + "version": "2.6.3", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-navigation/native", + "version": "7.1.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@react-navigation/native-stack", + "version": "7.3.16", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "@sentry/react-native", + "version": "7.2.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "axios", + "version": "1.7.9", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "axios-mock-adapter", + "version": "2.1.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "clsx", + "version": "2.1.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo", + "version": "54.0.33", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-asset", + "version": "12.0.12", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-av", + "version": "16.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-barcode-scanner", + "version": "12.0.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-battery", + "version": "55.0.13", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-build-properties", + "version": "1.0.10", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-constants", + "version": "18.0.13", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-crypto", + "version": "14.0.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-local-authentication", + "version": "14.0.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-device", + "version": "8.0.10", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-document-picker", + "version": "14.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-file-system", + "version": "19.0.23", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-font", + "version": "14.0.11", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-haptics", + "version": "15.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-image", + "version": "3.0.11", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-image-picker", + "version": "17.0.11", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-keep-awake", + "version": "15.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-linear-gradient", + "version": "15.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-linking", + "version": "8.0.11", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-location", + "version": "56.0.18", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-network", + "version": "8.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-notifications", + "version": "0.32.16", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-router", + "version": "6.0.23", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-secure-store", + "version": "15.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-sensors", + "version": "15.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-speech-recognition", + "version": "3.1.3", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-splash-screen", + "version": "31.0.13", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-status-bar", + "version": "3.0.9", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-symbols", + "version": "1.0.8", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-updates", + "version": "56.0.19", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-video", + "version": "3.0.16", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "expo-web-browser", + "version": "15.0.10", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "lucide-react-native", + "version": "0.562.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "nativewind", + "version": "4.2.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "prettier-plugin-tailwindcss", + "version": "0.5.14", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react", + "version": "19.1.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-dom", + "version": "19.1.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-hook-form", + "version": "7.80.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native", + "version": "0.81.5", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-gesture-handler", + "version": "2.28.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-iap", + "version": "15.2.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-mmkv", + "version": "4.3.2", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-reanimated", + "version": "4.1.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-safe-area-context", + "version": "5.6.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-screens", + "version": "4.16.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-svg", + "version": "15.12.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-web", + "version": "0.21.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-webview", + "version": "14.0.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "react-native-worklets", + "version": "0.5.1", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "socket.io-client", + "version": "4.8.3", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "web-vitals", + "version": "5.3.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "zod", + "version": "3.23.0", + "license": "See package / regenerate via npm run attribution" + }, + { + "name": "zustand", + "version": "5.0.10", + "license": "See package / regenerate via npm run attribution" + } + ] +} diff --git a/license-allowlist.json b/license-allowlist.json new file mode 100644 index 00000000..082cd5f8 --- /dev/null +++ b/license-allowlist.json @@ -0,0 +1,19 @@ +[ + "MIT", + "ISC", + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "BSD-2-Clause-FreeBSD", + "BSD-3-Clause-Clear", + "0BSD", + "Unlicense", + "CC0-1.0", + "CC-BY-4.0", + "MPL-2.0", + "Python-2.0", + "Zlib", + "WTFPL", + "BlueOak-1.0.0", + "MIT-0" +] diff --git a/license-exceptions.json b/license-exceptions.json new file mode 100644 index 00000000..c96faa50 --- /dev/null +++ b/license-exceptions.json @@ -0,0 +1,4 @@ +{ + "_comment": "Per-package license exceptions. Each key is an exact production dependency name mapped to a short approval reason. Add an entry here ONLY after review by a maintainer; disallowed licenses require explicit sign-off and must be deleted again once the package is replaced or relicensed. See scripts/README.md -> License policy.", + "packages": {} +} diff --git a/package.json b/package.json index 6c26b322..4ac59a21 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ "fonts:analyze": "node ./scripts/analyze-fonts.js", "audit": "node scripts/security-audit.js", "depcheck": "depcheck", + "license:check": "node scripts/license-audit.js", + "attribution": "node scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json", "audit:performance": "tsx src/audit/cli.ts", "audit:performance:html": "tsx src/audit/cli.ts --format html --output ./reports/audit-report", "audit:performance:all": "tsx src/audit/cli.ts --format all --output ./reports/audit-report", @@ -154,6 +156,7 @@ "js-yaml": "^4.2.0", "lint-staged": "^16.4.0", "lz-string": "^1.5.0", + "license-checker": "^25.0.1", "metro": "^0.83.7", "postcss": "^8.5.12", "prettier": "^3.8.3", diff --git a/scripts/README.md b/scripts/README.md index 8ff1bcc6..6f13dc00 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -240,6 +240,49 @@ npm run ci:monitor:report node scripts/testCacheInvalidation.js ``` +## Dependency Licensing + +Production dependencies are license-scanned on every PR. The scan covers the +**transitive production tree** (`npm install --production` scope) and fails the +build on any package whose license is not on the allowlist, unless that package +has an approved exception. + +```bash +# Validate that every production dependency license is allowed (fails build) +npm run license:check + +# Regenerate the committed attribution file used by the in-app Licenses screen +npm run attribution +``` + +### Policy files + +- **`license-allowlist.json`** — the only licenses that may appear in the + production tree. Intended to be permissive/OSI-approved and store-safe; it + explicitly excludes strong copyleft licenses (GPL, AGPL, SSPL, LGPL, CC-BY-SA). + Unknown/missing licenses are never allowed. + +### Exception review process + +- **`license-exceptions.json`** — any disallowed license requires a `packages` + entry mapping the exact dependency name to an approval reason. +- Adding an exception requires **maintainer sign-off**: the reviewer must verify + the package is essential, cannot be replaced by an allowlisted equivalent, and + note the reason for the record. Exceptions should be deleted again once the + package is replaced or relicensed. + +### Reconcile with the in-app auditor + +`src/audit/analyzers/NetworkAnalyzer.ts` reads the same two policy files so the +in-app auditor and the CI merge gate agree on scope (`--production` only) and on +the allowlist. There is no separate license blacklist to keep in sync. + +### In-app attribution + +The **Open Source Licenses** entry under *Settings → App* (route `/licenses`) +renders `assets/THIRD_PARTY_NOTICES.json`, a committed attribution file +regenerated from the full production tree by `npm run attribution`. + ## Related Documentation - [CI/CD Caching Strategy](../docs/CI_CD_CACHING_STRATEGY.md) - [Performance Monitoring](../docs/PERFORMANCE_MONITORING.md) diff --git a/scripts/license-audit.js b/scripts/license-audit.js new file mode 100644 index 00000000..d39a307d --- /dev/null +++ b/scripts/license-audit.js @@ -0,0 +1,147 @@ +/** + * Production dependency license audit. + * + * Scans the *transitive production* dependency tree (same scope as + * `npm install --production`) and fails on any package whose license is not + * on the allowlist defined in `license-allowlist.json`, unless that package is + * explicitly listed in `license-exceptions.json` with an approved reason. + * + * Policy files: + * - license-allowlist.json : the only licenses that may appear in the tree + * - license-exceptions.json : per-package exceptions (pkg -> approval reason) + * + * The allowlist and exception process are the single source of truth for + * dependency licensing. See the "License policy" section in scripts/README.md. + * + * Usage: + * node scripts/license-audit.js # validate, exit non-zero on violation + * node scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json + */ +'use strict'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const ROOT = path.resolve(__dirname, '..'); + +function loadJson(file) { + const full = path.join(ROOT, file); + if (!fs.existsSync(full)) { + throw new Error(`Missing required file: ${file}`); + } + return JSON.parse(fs.readFileSync(full, 'utf-8')); +} + +const ALLOWLIST = new Set(loadJson('license-allowlist.json')); +const EXCEPTIONS = loadJson('license-exceptions.json'); + +function normalizeLicense(raw) { + if (!raw) return 'UNKNOWN'; + return String(raw).trim(); +} + +/** + * A package is allowed if the whole package is under an approved exception, or + * if every license reported for it is on the allowlist. An unknown/missing + * license is never allowed (per spec: "failing on anything outside it"). + */ +function isAllowed(pkgName, rawLicenses) { + if (EXCEPTIONS[pkgName]) return true; + if (!rawLicenses) return false; + + const fragments = normalizeLicense(rawLicenses) + .split(/\s+(?:and|or)\s+/i) + .map((s) => s.trim()) + .filter(Boolean); + + if (fragments.length === 0) return false; + return fragments.every((f) => ALLOWLIST.has(f)); +} + +function collectProductionTree() { + // --production limits the scan to the production (non-dev) dependency tree. + const stdout = execFileSync( + path.join(ROOT, 'node_modules', '.bin', 'license-checker'), + ['--production', '--json'], + { cwd: ROOT, maxBuffer: 64 * 1024 * 1024, encoding: 'utf-8' } + ); + return JSON.parse(stdout); +} + +function main() { + const args = process.argv.slice(2); + const genIdx = args.indexOf('--generate-attribution'); + const genOut = genIdx !== -1 ? args[genIdx + 1] : null; + + let tree; + try { + tree = collectProductionTree(); + } catch (err) { + console.error('Failed to resolve the production dependency tree:', err.message); + process.exit(1); + } + + const violations = []; + const attributions = []; + + for (const key of Object.keys(tree).sort()) { + const entry = tree[key]; + const pkgName = key.split('@').slice(0, -1).join('@') || key; + + if (genOut) { + attributions.push({ + name: pkgName, + version: entry.version || '', + license: normalizeLicense(entry.licenses), + publisher: entry.publisher || undefined, + }); + } + + if (!isAllowed(pkgName, entry.licenses)) { + violations.push({ + packageName: pkgName, + license: normalizeLicense(entry.licenses), + reason: EXCEPTIONS[pkgName] + ? `unknown license not on allowlist: ${entry.licenses}` + : `license not on allowlist (${licenseAllowlistLabel()}): ${entry.licenses}`, + }); + } + } + + if (genOut) { + const outFile = path.join(ROOT, genOut); + fs.mkdirSync(path.dirname(outFile), { recursive: true }); + fs.writeFileSync( + outFile, + JSON.stringify( + { + generatedAt: new Date().toISOString(), + packages: attributions, + }, + null, + 2 + ) + '\n' + ); + console.log(`Wrote ${attributions.length} production packages to ${genOut}`); + } + + if (violations.length > 0) { + console.error('❌ LICENSE CHECK FAILED'); + console.error('The following production dependencies are not on the allowlist:'); + for (const v of violations) { + console.error(` - ${v.packageName} (${v.license}): ${v.reason}`); + } + console.error('\nReview license-allowlist.json and license-exceptions.json (see scripts/README.md).'); + process.exit(1); + } + + console.log(`✅ LICENSE CHECK PASSED (${Object.keys(tree).length} production packages)`); + process.exit(0); +} + +function licenseAllowlistLabel() { + return `allowlist has ${ALLOWLIST.size} license(s)`; +} + +main(); diff --git a/src/audit/analyzers/NetworkAnalyzer.ts b/src/audit/analyzers/NetworkAnalyzer.ts index 183cce52..1deffec6 100644 --- a/src/audit/analyzers/NetworkAnalyzer.ts +++ b/src/audit/analyzers/NetworkAnalyzer.ts @@ -445,44 +445,73 @@ export class DependencyAnalyzer implements IPerformanceAnalyzer { /** * Check license compliance + * + * Mirrors the authoritative CI policy (scripts/license-audit.js + the + * `license:check` npm script): scans the *production* dependency tree and + * fails on any package whose license is not on the allowlist declared in + * `license-allowlist.json`, unless the package is listed under an approved + * exception in `license-exceptions.json`. "UNKNOWN" licenses are never + * allowed. Keeping this in sync with the allowlist files means the in-app + * auditor and the merge gate agree. */ private async checkLicenseCompliance(): Promise { const issues: LicenseIssue[] = []; - const problematicLicenses = ['AGPL', 'SSPL', 'GPLv3']; - const pkgJsonPath = path.join(this.projectRoot, 'package.json'); + const allowlistPath = path.join(this.projectRoot, 'license-allowlist.json'); + const exceptionsPath = path.join(this.projectRoot, 'license-exceptions.json'); if (!fs.existsSync(pkgJsonPath)) return []; + let allowlist: string[] = []; try { - const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); - const allDeps = { - ...pkgJson.dependencies, - ...pkgJson.devDependencies, - }; + allowlist = JSON.parse(fs.readFileSync(allowlistPath, 'utf-8')); + } catch { + appLogger.errorSync('Error reading license-allowlist.json'); + return []; + } - for (const [name] of Object.entries(allDeps)) { + let exceptions: Record = {}; + try { + exceptions = JSON.parse(fs.readFileSync(exceptionsPath, 'utf-8')).packages ?? {}; + } catch { + // No exceptions file: rely purely on the allowlist. + exceptions = {}; + } + + const isAllowed = (pkgName: string, license?: unknown): boolean => { + if (exceptions[pkgName]) return true; + if (!license) return false; + const fragments = String(license) + .trim() + .split(/\s+(?:and|or)\s+/i) + .map((s) => s.trim()) + .filter(Boolean); + if (fragments.length === 0) return false; + return fragments.every((f) => allowlist.includes(f)); + }; + + try { + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')); + // Production scope only — devDependencies do not ship to the stores. + for (const name of Object.keys(pkgJson.dependencies ?? {})) { const pkgPath = path.join(this.projectRoot, 'node_modules', name, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const depPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); - const license = depPkg.license || 'UNKNOWN'; - - for (const problematic of problematicLicenses) { - if (license.includes(problematic)) { - issues.push({ - packageName: name, - license, - status: 'violation', - reason: `${problematic} license is not compatible with this project`, - }); - break; - } - } - } catch { - // Ignore + if (!fs.existsSync(pkgPath)) continue; + + try { + const depPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); + const license = depPkg.license || depPkg.licenses || 'UNKNOWN'; + + if (!isAllowed(name, license)) { + issues.push({ + packageName: name, + license: String(license), + status: 'violation', + reason: `license not on allowlist (${allowlist.join(', ')}): ${String(license)}`, + }); } + } catch { + // Ignore packages we cannot resolve locally. } } } catch (error) { diff --git a/src/components/mobile/LicensesScreen.tsx b/src/components/mobile/LicensesScreen.tsx new file mode 100644 index 00000000..01e348df --- /dev/null +++ b/src/components/mobile/LicensesScreen.tsx @@ -0,0 +1,68 @@ +import React, { useMemo } from 'react'; +import { FlatList, View } from 'react-native'; + +import { AppText } from '../common/AppText'; + +// Committed attribution snapshot generated by `npm run attribution` +// (scripts/license-audit.js --generate-attribution assets/THIRD_PARTY_NOTICES.json). +// CI regenerates the authoritative, full transitive production tree. +const THIRD_PARTY_NOTICES = require('../../../assets/THIRD_PARTY_NOTICES.json'); + +interface PackageAttribution { + name: string; + version?: string; + license: string; +} + +export const LicensesScreen = () => { + const packages = useMemo( + () => THIRD_PARTY_NOTICES.packages ?? [], + [] + ); + + const renderItem = ({ item }: { item: PackageAttribution }) => ( + + + + {item.name} + + {item.version ? ( + + v{item.version} + + ) : null} + + + + {item.license} + + + + ); + + return ( + + + + Open Source Licenses + + + This app is built on {packages.length} open-source packages. Licensing + is enforced in CI by an allowlist; see license-allowlist.json for the + accepted licenses and license-exceptions.json for approved exceptions. + + + `${item.name}-${index}`} + renderItem={renderItem} + contentContainerStyle={{ paddingBottom: 32 }} + ListEmptyComponent={ + + No attribution data available. + + } + /> + + ); +}; diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index ce72189e..a4399f0f 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -7,6 +7,7 @@ import { Database, Download, Eye, + FileText, Fingerprint as FingerprintPattern, Lock, LogOut, @@ -100,6 +101,7 @@ const ICON_CREDIT_CARD_YELLOW = ; const ICON_CREDIT_CARD_GREEN = ; const ICON_SUN = ; const ICON_DATABASE = ; +const ICON_FILE = ; const ICON_BAR_CHART = ; const ICON_TRASH_RED = ; const ICON_DOWNLOAD_INDIGO = ; @@ -506,6 +508,13 @@ export const MobileSettings = ({ onSignOut, onChangePassword, onLinkedAccounts } description="Reduces bandwidth by disabling prefetch and lowering image quality" right={dataSaverRight} /> + + router.push('/licenses')} + /> {/* ── PROGRESSIVE DISCLOSURE: ADVANCED SETTINGS ──────── */}