diff --git a/synkronus-portal/package.json b/synkronus-portal/package.json index 3057b9986..fafdb8234 100644 --- a/synkronus-portal/package.json +++ b/synkronus-portal/package.json @@ -20,6 +20,7 @@ "@ode/components": "file:../packages/components", "@ode/tokens": "file:../packages/tokens", "axios": "^1.13.0", + "qrcode": "^1.5.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-icons": "^5.5.0" @@ -28,6 +29,7 @@ "@eslint/js": "^9.39.2", "@openapitools/openapi-generator-cli": "^2.26.0", "@types/node": "^24.10.9", + "@types/qrcode": "^1.5.6", "@types/react": "^19.2.9", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.2", diff --git a/synkronus-portal/pnpm-lock.yaml b/synkronus-portal/pnpm-lock.yaml index f32a57995..d6d78444d 100644 --- a/synkronus-portal/pnpm-lock.yaml +++ b/synkronus-portal/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: axios: specifier: ^1.13.0 version: 1.16.1 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 react: specifier: ^19.2.4 version: 19.2.6 @@ -36,6 +39,9 @@ importers: '@types/node': specifier: ^24.10.9 version: 24.12.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/react': specifier: ^19.2.9 version: 19.2.14 @@ -751,6 +757,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -999,6 +1008,10 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -1033,6 +1046,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1143,6 +1159,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1173,6 +1193,9 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1429,6 +1452,10 @@ packages: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1837,6 +1864,10 @@ packages: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2083,14 +2114,26 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -2136,6 +2179,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2182,6 +2229,11 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} @@ -2258,6 +2310,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -2315,6 +2370,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2651,6 +2709,9 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.20: resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} engines: {node: '>= 0.4'} @@ -2684,6 +2745,9 @@ packages: utf-8-validate: optional: true + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2696,10 +2760,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -3337,6 +3409,10 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 24.12.4 + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -3649,6 +3725,8 @@ snapshots: callsites@3.1.0: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001792: {} @@ -3685,6 +3763,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -3793,6 +3877,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + deep-is@0.1.4: {} defaults@1.0.4: @@ -3824,6 +3910,8 @@ snapshots: destroy@1.2.0: {} + dijkstrajs@1.0.3: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4197,6 +4285,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4621,6 +4714,10 @@ snapshots: load-esm@1.0.3: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4963,14 +5060,24 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 @@ -5014,6 +5121,8 @@ snapshots: picomatch@4.0.4: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} postcss@8.5.14: @@ -5065,6 +5174,12 @@ snapshots: punycode@2.3.1: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + queue@6.0.2: dependencies: inherits: 2.0.4 @@ -5176,6 +5291,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + resolve-from@4.0.0: {} resolve@2.0.0-next.7: @@ -5278,6 +5395,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -5645,6 +5764,8 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 + which-module@2.0.1: {} + which-typed-array@1.1.20: dependencies: available-typed-arrays: 1.0.7 @@ -5675,14 +5796,35 @@ snapshots: ws@7.5.10: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yaml@2.9.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/synkronus-portal/src/assets/qr_logo.png b/synkronus-portal/src/assets/qr_logo.png new file mode 100644 index 000000000..8917ebbb7 Binary files /dev/null and b/synkronus-portal/src/assets/qr_logo.png differ diff --git a/synkronus-portal/src/components/FormulusOnboardingModal.css b/synkronus-portal/src/components/FormulusOnboardingModal.css new file mode 100644 index 000000000..a5238de7a --- /dev/null +++ b/synkronus-portal/src/components/FormulusOnboardingModal.css @@ -0,0 +1,244 @@ +.modal-content.modal-formulus-onboarding { + max-width: 640px; + padding: 0; + overflow: hidden; +} + +.formulus-onboarding-overlay { + /* One-time credentials: require explicit close, not backdrop click. */ + cursor: default; +} + +.formulus-onboarding-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-3); + padding: var(--spacing-5) var(--spacing-5) var(--spacing-4); + border-bottom: var(--border-width-thin) solid + var(--color-neutral-white-alpha-10); +} + +.formulus-onboarding-header h2 { + margin: 0; + font-size: var(--font-size-xl); + font-weight: var(--font-weight-medium); + color: var(--color-neutral-white); + line-height: 1.25; +} + +.formulus-onboarding-subtitle { + margin: var(--spacing-2) 0 0; + max-width: 36rem; + font-size: var(--font-size-sm); + line-height: 1.45; + color: var(--color-neutral-white-alpha-70); +} + +.formulus-onboarding-layout { + display: grid; + grid-template-columns: minmax(220px, 240px) minmax(0, 1fr); + gap: var(--spacing-5); + padding: var(--spacing-5); + align-items: start; +} + +.formulus-qr-column { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + align-items: stretch; +} + +.formulus-qr-frame { + display: flex; + align-items: center; + justify-content: center; + aspect-ratio: 1; + width: 100%; + padding: var(--spacing-3); + border-radius: var(--border-radius-lg); + background: #fff; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.06); +} + +.formulus-qr-frame.is-loading { + background: var(--color-neutral-100, #f3f4f6); +} + +.formulus-qr-image { + width: 100%; + height: auto; + display: block; +} + +.formulus-qr-status { + font-size: var(--font-size-sm); + color: var(--color-neutral-600); + text-align: center; + padding: var(--spacing-2); +} + +.formulus-qr-error { + color: var(--color-semantic-danger, #b91c1c); +} + +.formulus-download-btn { + width: 100%; + justify-content: center; +} + +.formulus-details-column { + display: flex; + flex-direction: column; + gap: var(--spacing-3); + min-width: 0; +} + +.formulus-field { + padding: var(--spacing-3); + border-radius: var(--border-radius-md); + background: var(--color-neutral-800); + border: var(--border-width-thin) solid var(--color-neutral-700); +} + +.formulus-field-top { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-2); + margin-bottom: var(--spacing-1); +} + +.formulus-field-label { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-medium); + letter-spacing: 0.02em; + color: var(--color-neutral-400); +} + +.formulus-field-actions { + display: inline-flex; + align-items: center; + gap: 2px; +} + +.formulus-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: var(--border-radius-md); + background: transparent; + color: var(--color-neutral-400); + cursor: pointer; + transition: + color var(--duration-fast) var(--easing-ease-out), + background var(--duration-fast) var(--easing-ease-out); +} + +.formulus-icon-btn:hover { + background: var(--color-neutral-700); + color: var(--color-neutral-50); +} + +.formulus-field-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: var(--font-size-sm); + line-height: 1.4; + color: var(--color-neutral-50); + word-break: break-all; +} + +.formulus-password-value.is-masked { + letter-spacing: 0.12em; + user-select: none; +} + +.formulus-server-field { + margin-top: var(--spacing-1); +} + +.formulus-server-hint { + margin: var(--spacing-1) 0 0; + font-size: var(--font-size-xs); + line-height: 1.35; + color: var(--color-neutral-500); +} + +.formulus-onboarding-footer { + display: flex; + justify-content: flex-end; + margin-top: var(--spacing-2); + padding-top: var(--spacing-3); + border-top: var(--border-width-thin) solid var(--color-neutral-white-alpha-10); +} + +[data-theme='light'] .formulus-onboarding-header { + border-bottom-color: var(--color-neutral-black-alpha-10, rgba(0, 0, 0, 0.08)); +} + +[data-theme='light'] .formulus-onboarding-header h2 { + color: var(--color-text-primary, #111827); +} + +[data-theme='light'] .formulus-onboarding-subtitle { + color: var(--color-text-secondary, #6b7280); +} + +[data-theme='light'] .formulus-field { + background: var(--color-neutral-50, #f9fafb); + border-color: var(--color-neutral-200, #e5e7eb); +} + +[data-theme='light'] .formulus-field-label { + color: var(--color-neutral-500, #6b7280); +} + +[data-theme='light'] .formulus-field-value { + color: var(--color-neutral-900, #111827); +} + +[data-theme='light'] .formulus-icon-btn { + color: var(--color-neutral-500, #6b7280); +} + +[data-theme='light'] .formulus-icon-btn:hover { + background: var(--color-neutral-200, #e5e7eb); + color: var(--color-neutral-900, #111827); +} + +[data-theme='light'] .formulus-server-hint { + color: var(--color-neutral-500, #6b7280); +} + +[data-theme='light'] .formulus-onboarding-footer { + border-top-color: var(--color-neutral-black-alpha-10, rgba(0, 0, 0, 0.08)); +} + +@media (max-width: 640px) { + .modal-content.modal-formulus-onboarding { + max-width: 420px; + } + + .formulus-onboarding-layout { + grid-template-columns: 1fr; + } + + .formulus-qr-column { + max-width: 220px; + margin: 0 auto; + width: 100%; + } + + .formulus-onboarding-footer { + justify-content: stretch; + } + + .formulus-onboarding-footer .ode-button, + .formulus-onboarding-footer button { + width: 100%; + } +} diff --git a/synkronus-portal/src/components/FormulusOnboardingModal.tsx b/synkronus-portal/src/components/FormulusOnboardingModal.tsx new file mode 100644 index 000000000..216723189 --- /dev/null +++ b/synkronus-portal/src/components/FormulusOnboardingModal.tsx @@ -0,0 +1,224 @@ +import { useDeferredValue, useEffect, useState } from 'react'; +import { Button, Input } from '@ode/components/react-web'; +import { + HiArrowDownTray, + HiCheck, + HiClipboard, + HiEye, + HiEyeSlash, +} from 'react-icons/hi2'; +import { + downloadDataUrl, + generateFormulusQrDataUrl, + sanitizeQrFilename, +} from '../utils/formulusQr'; +import './FormulusOnboardingModal.css'; + +export type FormulusOnboardingCredentials = { + username: string; + password: string; + serverUrl: string; +}; + +type FormulusOnboardingModalProps = { + credentials: FormulusOnboardingCredentials; + onClose: () => void; +}; + +type CopyField = 'username' | 'password'; + +export function FormulusOnboardingModal({ + credentials, + onClose, +}: FormulusOnboardingModalProps) { + const [serverUrl, setServerUrl] = useState(credentials.serverUrl); + const deferredServerUrl = useDeferredValue(serverUrl.trim()); + const [qrDataUrl, setQrDataUrl] = useState(null); + const [qrError, setQrError] = useState(null); + const [generating, setGenerating] = useState(true); + const [showPassword, setShowPassword] = useState(false); + const [copiedField, setCopiedField] = useState(null); + + useEffect(() => { + let cancelled = false; + + const run = async () => { + if (!deferredServerUrl) { + setQrDataUrl(null); + setQrError('Enter a server URL'); + setGenerating(false); + return; + } + + setGenerating(true); + setQrError(null); + try { + const url = await generateFormulusQrDataUrl( + deferredServerUrl, + credentials.username, + credentials.password, + ); + if (!cancelled) setQrDataUrl(url); + } catch (err) { + if (!cancelled) { + setQrDataUrl(null); + setQrError( + err instanceof Error ? err.message : 'Failed to generate QR code', + ); + } + } finally { + if (!cancelled) setGenerating(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [deferredServerUrl, credentials.username, credentials.password]); + + const handleDownload = () => { + if (!qrDataUrl) return; + downloadDataUrl(qrDataUrl, sanitizeQrFilename(credentials.username)); + }; + + const copyText = async (field: CopyField, value: string) => { + try { + await navigator.clipboard.writeText(value); + setCopiedField(field); + window.setTimeout(() => setCopiedField(null), 1500); + } catch { + // Clipboard may be denied; reveal password for manual copy if needed. + if (field === 'password') setShowPassword(true); + } + }; + + const passwordDisplay = showPassword + ? credentials.password + : '•'.repeat(Math.min(18, Math.max(12, credentials.password.length))); + + return ( +
+
e.stopPropagation()}> +
+
+

Share with Formulus

+

+ Scan this QR in Formulus Settings. It is shown once and cannot be + recovered later. +

+
+ +
+ +
+
+
+ {generating && ( +
+ Generating… +
+ )} + {!generating && qrError && ( +
+ {qrError} +
+ )} + {!generating && qrDataUrl && ( + {`Formulus + )} +
+ +
+ +
+
+
+ Username + +
+
{credentials.username}
+
+ +
+
+ Password +
+ + +
+
+
+ {passwordDisplay} +
+
+ +
+ +

+ Must be reachable from the device that scans the QR. +

+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/synkronus-portal/src/pages/Dashboard.css b/synkronus-portal/src/pages/Dashboard.css index 99251d393..bb298718b 100644 --- a/synkronus-portal/src/pages/Dashboard.css +++ b/synkronus-portal/src/pages/Dashboard.css @@ -2996,6 +2996,99 @@ html::-webkit-scrollbar-thumb:hover { transform: rotate(90deg); } +.suggest-password-btn { + display: inline-flex; + align-items: center; + gap: var(--spacing-1); + padding: 0; + border: none; + background: transparent; + color: var(--color-brand-primary-400, #60a5fa); + font-size: var(--font-size-xs); + cursor: pointer; + white-space: nowrap; +} + +.suggest-password-btn:hover:not(:disabled) { + color: var(--color-brand-primary-300, #93c5fd); + text-decoration: underline; +} + +.suggest-password-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.password-field { + position: relative; +} + +.password-field .modal-input, +.password-field .ode-input { + width: 100%; +} + +.password-field .modal-input input, +.password-field .ode-input input { + padding-right: 2.5rem !important; +} + +.password-field-toggle { + position: absolute; + right: var(--spacing-2); + bottom: 0; + height: 38px; + width: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--color-neutral-400); + cursor: pointer; + border-radius: var(--border-radius-md); +} + +.password-field-toggle:hover:not(:disabled) { + color: var(--color-neutral-50); +} + +.password-field-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.password-field-meta { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-2); + margin-top: var(--spacing-1); +} + +.password-field-hint { + margin: 0; + font-size: var(--font-size-xs); + line-height: 1.35; + color: var(--color-neutral-400); +} + +[data-theme='light'] .suggest-password-btn { + color: var(--color-brand-primary-600, #2563eb); +} + +[data-theme='light'] .password-field-toggle { + color: var(--color-neutral-500); +} + +[data-theme='light'] .password-field-toggle:hover:not(:disabled) { + color: var(--color-neutral-900); +} + +[data-theme='light'] .password-field-hint { + color: var(--color-neutral-500); +} + [data-theme='light'] .modal-close { color: var(--color-text-primary); } diff --git a/synkronus-portal/src/pages/Dashboard.tsx b/synkronus-portal/src/pages/Dashboard.tsx index 7aa114024..88613d404 100644 --- a/synkronus-portal/src/pages/Dashboard.tsx +++ b/synkronus-portal/src/pages/Dashboard.tsx @@ -4,6 +4,12 @@ import { useTheme } from '../contexts/ThemeContext'; import { api } from '../services/api'; import { Button, Input, Badge } from '@ode/components/react-web'; import { ThemeSwitcher } from '../components/ThemeSwitcher'; +import { + FormulusOnboardingModal, + type FormulusOnboardingCredentials, +} from '../components/FormulusOnboardingModal'; +import { getFormulusServerUrl } from '../utils/formulusServerUrl'; +import { generateStrongPassword } from '../utils/password'; import { HiOutlineCube, @@ -23,6 +29,8 @@ import { HiPlus, HiXMark, HiChartBar, + HiEye, + HiEyeSlash, } from 'react-icons/hi2'; import { ColorBrandPrimary500 } from '@ode/tokens'; import portalLogo from '../assets/portal.png'; @@ -90,10 +98,17 @@ export function Dashboard() { // User management modals const [showCreateUserModal, setShowCreateUserModal] = useState(false); const [showResetPasswordModal, setShowResetPasswordModal] = useState(false); + const [showResetPasswordConfirm, setShowResetPasswordConfirm] = + useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState( null, ); + /** One-time Formulus QR handoff; cleared on dismiss — never persisted. */ + const [formulusOnboarding, setFormulusOnboarding] = + useState(null); + const [showCreatePassword, setShowCreatePassword] = useState(false); + const [showResetPasswordValue, setShowResetPasswordValue] = useState(false); // Form states const [createUserForm, setCreateUserForm] = useState({ @@ -109,17 +124,27 @@ export function Dashboard() { // Clear form when modal opens/closes const handleOpenCreateUserModal = () => { - setCreateUserForm({ username: '', password: '', role: 'read-only' }); + setCreateUserForm({ + username: '', + password: generateStrongPassword(), + role: 'read-only', + }); + setShowCreatePassword(false); setRoleDropdownOpen(false); setShowCreateUserModal(true); }; const handleCloseCreateUserModal = () => { setCreateUserForm({ username: '', password: '', role: 'read-only' }); + setShowCreatePassword(false); setRoleDropdownOpen(false); setShowCreateUserModal(false); }; + const handleCloseFormulusOnboarding = () => { + setFormulusOnboarding(null); + }; + const handleRoleSelect = (role: string) => { setCreateUserForm({ ...createUserForm, role }); setRoleDropdownOpen(false); @@ -450,15 +475,22 @@ export function Dashboard() { setError('Username and password are required'); return; } + const createdUsername = createUserForm.username; + const createdPassword = createUserForm.password; setLoading(true); setError(null); setSuccess(null); try { await api.createUser(createUserForm); setSuccess('User created successfully!'); - // Clear form immediately + // Clear form immediately (plaintext kept only in one-time QR modal state) setCreateUserForm({ username: '', password: '', role: 'read-only' }); setShowCreateUserModal(false); + setFormulusOnboarding({ + username: createdUsername, + password: createdPassword, + serverUrl: getFormulusServerUrl(), + }); await loadUsers(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create user'); @@ -484,35 +516,64 @@ export function Dashboard() { } }; - const handleResetPassword = async (e: React.FormEvent) => { + const handleResetPassword = (e: React.FormEvent) => { e.preventDefault(); if (!resetPasswordForm.username || !resetPasswordForm.newPassword) { setError('Username and new password are required'); return; } + setError(null); + setShowResetPasswordConfirm(true); + }; + + const handleCloseResetPasswordModal = () => { + setResetPasswordForm({ username: '', newPassword: '' }); + setShowResetPasswordValue(false); + setShowResetPasswordConfirm(false); + setShowResetPasswordModal(false); + }; + + const handleOpenResetPasswordModal = (username = '') => { + setResetPasswordForm({ + username, + newPassword: generateStrongPassword(), + }); + setShowResetPasswordValue(false); + setShowResetPasswordConfirm(false); + setShowResetPasswordModal(true); + }; + + const handleConfirmResetPassword = async () => { + if (!resetPasswordForm.username || !resetPasswordForm.newPassword) { + setShowResetPasswordConfirm(false); + setError('Username and new password are required'); + return; + } + const resetUsername = resetPasswordForm.username; + const resetPassword = resetPasswordForm.newPassword; setLoading(true); setError(null); setSuccess(null); try { await api.resetPassword(resetPasswordForm); - setSuccess( - `Password reset successfully for ${resetPasswordForm.username}!`, - ); - // Clear form immediately + setSuccess(`Password reset successfully for ${resetUsername}!`); setResetPasswordForm({ username: '', newPassword: '' }); + setShowResetPasswordValue(false); + setShowResetPasswordConfirm(false); setShowResetPasswordModal(false); + setFormulusOnboarding({ + username: resetUsername, + password: resetPassword, + serverUrl: getFormulusServerUrl(), + }); } catch (err) { + setShowResetPasswordConfirm(false); setError(err instanceof Error ? err.message : 'Failed to reset password'); } finally { setLoading(false); } }; - const handleCloseResetPasswordModal = () => { - setResetPasswordForm({ username: '', newPassword: '' }); - setShowResetPasswordModal(false); - }; - const handleChangePassword = async (e: React.FormEvent) => { e.preventDefault(); if ( @@ -1160,13 +1221,9 @@ export function Dashboard() {
- - setCreateUserForm({ ...createUserForm, password: text }) - } - required - disabled={loading} - className="modal-input" - /> +
+ + setCreateUserForm({ ...createUserForm, password: text }) + } + required + disabled={loading} + className="modal-input" + /> + +
+
+

+ A strong password was suggested. Edit it, or generate a new + one. +

+ +
@@ -1524,15 +1612,13 @@ export function Dashboard() { {/* Reset Password Modal */} {showResetPasswordModal && ( -
setShowResetPasswordModal(false)}> +
e.stopPropagation()}>

Reset Password

@@ -1557,20 +1643,51 @@ export function Dashboard() { />
- - setResetPasswordForm({ - ...resetPasswordForm, - newPassword: text, - }) - } - required - disabled={loading} - className="modal-input" - /> +
+ + setResetPasswordForm({ + ...resetPasswordForm, + newPassword: text, + }) + } + required + disabled={loading} + className="modal-input" + /> + +
+
+

+ A strong password was suggested. Edit it, or generate a new + one. +

+ +
+
+
+

+ Are you sure you want to reset the password for{' '} + {resetPasswordForm.username}? +

+
+
+ + +
+
+
+ )} + {/* Delete Confirmation Modal */} {showDeleteConfirm && (
setShowDeleteConfirm(null)}> + onClick={() => !loading && setShowDeleteConfirm(null)}>
e.stopPropagation()}>
-

Delete User

+

Confirm delete

- Are you sure you want to delete user{' '} - {showDeleteConfirm}? This action cannot be - undone. + Are you sure you want to delete{' '} + {showDeleteConfirm}?

diff --git a/synkronus-portal/src/services/api.ts b/synkronus-portal/src/services/api.ts index 2e1d278f7..40c58b250 100644 --- a/synkronus-portal/src/services/api.ts +++ b/synkronus-portal/src/services/api.ts @@ -242,15 +242,10 @@ export const api = { }, async deleteUser(username: string): Promise<{ message: string }> { - try { - const res = await defaultApi.deleteUser({ - username, - xOdeVersion: ODE_VERSION, - }); - return res.data as { message: string }; - } catch (error) { - throw toApiError(error); - } + return requestJson<{ message: string }>( + `/users/delete/${encodeURIComponent(username)}`, + 'DELETE', + ); }, async resetPassword(data: { diff --git a/synkronus-portal/src/utils/formulusQr.ts b/synkronus-portal/src/utils/formulusQr.ts new file mode 100644 index 000000000..eaf0c948d --- /dev/null +++ b/synkronus-portal/src/utils/formulusQr.ts @@ -0,0 +1,70 @@ +import QRCode from 'qrcode'; +import qrLogoUrl from '../assets/qr_logo.png'; +import { encodeFormulusLoginQr } from './frmls'; + +const QR_SIZE = 1060; +const QR_MARGIN_MODULES = 2; +const MAX_LOGO_RATIO = 0.2; + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve(image); + image.onerror = () => reject(new Error('Failed to load QR logo')); + image.src = src; + }); +} + +/** + * Render an FRMLS login QR in the portal with the same visual treatment as + * `synk qr`: black square modules, white background, and the round ODE logo + * centred at no more than one fifth of the QR width. + */ +export async function generateFormulusQrDataUrl( + serverUrl: string, + username: string, + password: string, +): Promise { + const canvas = document.createElement('canvas'); + await QRCode.toCanvas( + canvas, + encodeFormulusLoginQr(serverUrl, username, password), + { + width: QR_SIZE, + margin: QR_MARGIN_MODULES, + errorCorrectionLevel: 'H', + color: { + dark: '#000000', + light: '#ffffff', + }, + }, + ); + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not create QR image'); + } + + const logo = await loadImage(qrLogoUrl); + const maxLogoSize = Math.floor(QR_SIZE * MAX_LOGO_RATIO); + const logoSize = Math.min(logo.naturalWidth, logo.naturalHeight, maxLogoSize); + const offset = Math.floor((QR_SIZE - logoSize) / 2); + context.drawImage(logo, offset, offset, logoSize, logoSize); + + return canvas.toDataURL('image/png'); +} + +export function downloadDataUrl(dataUrl: string, filename: string): void { + const link = document.createElement('a'); + link.href = dataUrl; + link.download = filename; + link.rel = 'noopener'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +export function sanitizeQrFilename(username: string): string { + const safe = username.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_|_$/g, ''); + return `formulus-qr-${safe || 'user'}.png`; +} diff --git a/synkronus-portal/src/utils/formulusServerUrl.ts b/synkronus-portal/src/utils/formulusServerUrl.ts new file mode 100644 index 000000000..1bf75e3d8 --- /dev/null +++ b/synkronus-portal/src/utils/formulusServerUrl.ts @@ -0,0 +1,25 @@ +/** + * Derive the Synkronus origin URL that Formulus should connect to. + * Matches CLI `api.url` semantics (origin, not portal `/api` proxy path). + */ +export function getFormulusServerUrl(): string { + const viteUrl = (import.meta.env.VITE_API_URL as string | undefined)?.trim(); + + if (viteUrl && /^https?:\/\//i.test(viteUrl)) { + try { + return new URL(viteUrl).origin; + } catch { + return viteUrl.replace(/\/api\/?$/, '').replace(/\/$/, ''); + } + } + + const { protocol, hostname, port } = window.location; + + // Vite portal proxies /api to Synkronus; Formulus needs the API origin. + if (port === '5174' || port === '5173') { + return `${protocol}//${hostname}:8080`; + } + + // Production: portal is embedded in Synkronus on the same origin. + return window.location.origin; +} diff --git a/synkronus-portal/src/utils/frmls.ts b/synkronus-portal/src/utils/frmls.ts new file mode 100644 index 000000000..c737c38fd --- /dev/null +++ b/synkronus-portal/src/utils/frmls.ts @@ -0,0 +1,41 @@ +export type FRMLS = { + v: number; + s: string; + u: string; + p: string; +}; + +function encodeBase64(value: string): string { + const bytes = new TextEncoder().encode(value); + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary); +} + +/** + * Encode Formulus settings using the FRMLS v1 format used by `synk qr`. + */ +export function encodeFRMLS(settings: FRMLS): string { + const parts = [ + `v:${encodeBase64(String(settings.v))}`, + `s:${encodeBase64(settings.s)}`, + `u:${encodeBase64(settings.u)}`, + `p:${encodeBase64(settings.p)}`, + ]; + return `FRMLS:${parts.join(';')};;`; +} + +export function encodeFormulusLoginQr( + serverUrl: string, + username: string, + password: string, +): string { + return encodeFRMLS({ + v: 1, + s: serverUrl, + u: username, + p: password, + }); +} diff --git a/synkronus-portal/src/utils/password.ts b/synkronus-portal/src/utils/password.ts new file mode 100644 index 000000000..8cfe9ddc2 --- /dev/null +++ b/synkronus-portal/src/utils/password.ts @@ -0,0 +1,39 @@ +/** + * Generate a strong random password suitable for Formulus onboarding handoff. + * Uses crypto.getRandomValues; excludes ambiguous characters (0/O, 1/l/I). + */ +const UPPER = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; +const LOWER = 'abcdefghijkmnopqrstuvwxyz'; +const DIGITS = '23456789'; +const SYMBOLS = '!@#$%^&*-_=+'; +const ALL = UPPER + LOWER + DIGITS + SYMBOLS; + +function randomIndex(max: number): number { + const buf = new Uint32Array(1); + crypto.getRandomValues(buf); + return buf[0]! % max; +} + +function pick(charset: string): string { + return charset[randomIndex(charset.length)]!; +} + +export function generateStrongPassword(length = 16): string { + const len = Math.max(12, length); + // Guarantee at least one of each class, then fill the rest. + const chars: string[] = [ + pick(UPPER), + pick(LOWER), + pick(DIGITS), + pick(SYMBOLS), + ]; + for (let i = chars.length; i < len; i++) { + chars.push(pick(ALL)); + } + // Fisher–Yates shuffle + for (let i = chars.length - 1; i > 0; i--) { + const j = randomIndex(i + 1); + [chars[i], chars[j]] = [chars[j]!, chars[i]!]; + } + return chars.join(''); +}