diff --git a/package.json b/package.json
index 7f7ffc4..920bbe8 100644
--- a/package.json
+++ b/package.json
@@ -13,6 +13,7 @@
"lint": "npx prettier . --write && eslint --fix .",
"dev": "tsc && eslint . && npx prettier . --write && vite --logLevel info -d=debug --host 0.0.0.0",
"build": "vite build",
+ "test": "vitest run",
"preview": "vite preview --logLevel info -d=debug --host 0.0.0.0"
},
"dependencies": {
@@ -38,6 +39,7 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dotenv": "^17.4.2",
+ "formik": "^2.4.9",
"i18next": "^26.4.2",
"i18next-browser-languagedetector": "^8.2.1",
"js-yaml": "4.3.2",
@@ -52,6 +54,7 @@
"sonner": "^2.0.8",
"tailwind-merge": "^3.7.0",
"tailwindcss-animate": "^1.0.7",
+ "yup": "^1.7.1",
"zustand": "^5.0.15"
},
"devDependencies": {
@@ -73,6 +76,7 @@
"typescript": "^5.9.3",
"typescript-eslint": "^8.70.0",
"vite": "8.3.0",
+ "vitest": "^5.0.3",
"yargs": "^18.1.0"
},
"pnpm": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b05c7e..083cc00 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
+ formik:
+ specifier: ^2.4.9
+ version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
i18next:
specifier: ^26.4.2
version: 26.4.2(typescript@5.9.3)
@@ -116,6 +119,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.19)
+ yup:
+ specifier: ^1.7.1
+ version: 1.7.1
zustand:
specifier: ^5.0.15
version: 5.0.15(@types/react@18.3.31)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
@@ -174,6 +180,9 @@ importers:
vite:
specifier: 8.3.0
version: 8.3.0(@types/node@24.13.4)(jiti@1.21.7)
+ vitest:
+ specifier: ^5.0.3
+ version: 5.0.3(@types/node@24.13.4)(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7))
yargs:
specifier: ^18.1.0
version: 18.1.0
@@ -271,6 +280,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
@@ -852,6 +864,9 @@ packages:
peerDependencies:
react: ^18 || ^19
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
@@ -870,12 +885,20 @@ packages:
'@types/d3-zoom@3.0.8':
resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@types/hast@3.0.5':
resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==}
+ '@types/hoist-non-react-statics@3.3.7':
+ resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
+ peerDependencies:
+ '@types/react': '*'
+
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
@@ -980,6 +1003,20 @@ packages:
oxc-transform-react:
optional: true
+ '@vitest/mocker@5.0.3':
+ resolution: {integrity: sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/spy@5.0.3':
+ resolution: {integrity: sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==}
+
'@xyflow/react@12.11.6':
resolution: {integrity: sha512-9XsEJNHjatKYndszKTF/bsU7FOP9dJ6V/EQwzy3oMdtqgBuUq7BjKSwkEo+C7s4qHstHQfwwoHA3E8QfpPxZZQ==}
peerDependencies:
@@ -1070,6 +1107,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
async-function@1.0.0:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
@@ -1146,6 +1187,10 @@ packages:
caniuse-lite@1.0.30001810:
resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==}
+ chai@6.3.0:
+ resolution: {integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==}
+ engines: {node: '>=18'}
+
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@@ -1285,6 +1330,10 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ deepmerge@2.2.1:
+ resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
+ engines: {node: '>=0.10.0'}
+
define-data-property@1.1.4:
resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==}
engines: {node: '>= 0.4'}
@@ -1348,6 +1397,9 @@ packages:
resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.3.2:
+ resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==}
+
es-object-atoms@1.1.2:
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
engines: {node: '>= 0.4'}
@@ -1436,10 +1488,17 @@ packages:
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
engines: {node: '>=4.0'}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
+ expect-type@1.4.0:
+ resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
+ engines: {node: '>=12.0.0'}
+
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
@@ -1508,6 +1567,11 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
+ formik@2.4.9:
+ resolution: {integrity: sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==}
+ peerDependencies:
+ react: '>=16.8.0'
+
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
@@ -1617,6 +1681,9 @@ packages:
highlightjs-vue@1.0.0:
resolution: {integrity: sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==}
+ hoist-non-react-statics@3.3.2:
+ resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
+
html-parse-stringify@4.0.1:
resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==}
@@ -1905,9 +1972,15 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
+ lodash-es@4.18.1:
+ resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
+
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
+ lodash@4.18.1:
+ resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
+
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@@ -1920,6 +1993,9 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ magic-string@1.4.3:
+ resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -2005,6 +2081,10 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
+ obug@2.2.1:
+ resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==}
+ engines: {node: '>=12.20.0'}
+
oidc-client-ts@3.5.0:
resolution: {integrity: sha512-l2q8l9CTCTOlbX+AnK4p3M+4CEpKpyQhle6blQkdFhm0IsBqsxm15bYaSa11G7pWdsYr6epdsRZxJpCyCRbT8A==}
engines: {node: '>=18'}
@@ -2129,6 +2209,9 @@ packages:
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
+ property-expr@2.0.6:
+ resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==}
+
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -2148,6 +2231,9 @@ packages:
peerDependencies:
react: ^18.3.1
+ react-fast-compare@2.0.4:
+ resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
+
react-i18next@17.0.13:
resolution: {integrity: sha512-Cc1PscmblIHA1kljTqDwrcVMI21ydgmUzw0UAeQBe7pAOgfuRLfzXze4EUBQoeDiICzFIXXhHFoZxuetNg5D0Q==}
peerDependencies:
@@ -2353,6 +2439,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ std-env@4.3.0:
+ resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==}
+
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@@ -2425,6 +2514,20 @@ packages:
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
+ tiny-case@1.0.3:
+ resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
+
+ tiny-warning@1.0.3:
+ resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
+
+ tinybench@6.2.0:
+ resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==}
+ engines: {node: '>=20.0.0'}
+
+ tinyexec@1.3.1:
+ resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==}
+ engines: {node: '>=18'}
+
tinyglobby@0.2.17:
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
engines: {node: '>=12.0.0'}
@@ -2433,6 +2536,9 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ toposort@2.0.2:
+ resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==}
+
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -2449,6 +2555,10 @@ packages:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
+ type-fest@2.19.0:
+ resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==}
+ engines: {node: '>=12.20'}
+
typed-array-buffer@1.0.3:
resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==}
engines: {node: '>= 0.4'}
@@ -2564,6 +2674,47 @@ packages:
yaml:
optional: true
+ vitest@5.0.3:
+ resolution: {integrity: sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==}
+ engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 5.0.3
+ '@vitest/browser-preview': 5.0.3
+ '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0
+ '@vitest/coverage-istanbul': 5.0.3
+ '@vitest/coverage-v8': 5.0.3
+ '@vitest/ui': 5.0.3
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.4.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/coverage-istanbul':
+ optional: true
+ '@vitest/coverage-v8':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
which-boxed-primitive@1.1.1:
resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==}
engines: {node: '>= 0.4'}
@@ -2585,6 +2736,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
+ why-is-node-running@3.2.1:
+ resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
+ engines: {node: '>=20.11'}
+ hasBin: true
+
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -2609,6 +2765,9 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yup@1.7.1:
+ resolution: {integrity: sha512-GKHFX2nXul2/4Dtfxhozv701jLQHdf6J34YDh2cEkpqoo8le5Mg6/LrdseVLrFarmFygZTlfIhHx/QKfb/QWXw==}
+
zustand@4.5.7:
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
engines: {node: '>=12.7.0'}
@@ -2736,6 +2895,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.5': {}
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
'@jridgewell/trace-mapping@0.3.31':
dependencies:
'@jridgewell/resolve-uri': 3.1.2
@@ -3271,6 +3432,11 @@ snapshots:
'@tanstack/query-core': 5.102.8
react: 18.3.1
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/d3-color@3.1.3': {}
'@types/d3-drag@3.0.7':
@@ -3292,12 +3458,19 @@ snapshots:
'@types/d3-interpolate': 3.0.4
'@types/d3-selection': 3.0.11
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree@1.0.9': {}
'@types/hast@3.0.5':
dependencies:
'@types/unist': 3.0.3
+ '@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31)':
+ dependencies:
+ '@types/react': 18.3.31
+ hoist-non-react-statics: 3.3.2
+
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
@@ -3419,6 +3592,17 @@ snapshots:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7)
+ '@vitest/mocker@5.0.3(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7))':
+ dependencies:
+ '@jridgewell/trace-mapping': 0.3.31
+ '@vitest/spy': 5.0.3
+ estree-walker: 3.0.3
+ magic-string: 1.4.3
+ optionalDependencies:
+ vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7)
+
+ '@vitest/spy@5.0.3': {}
+
'@xyflow/react@12.11.6(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@xyflow/system': 0.0.82
@@ -3543,6 +3727,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
+ assertion-error@2.0.1: {}
+
async-function@1.0.0: {}
asynckit@0.4.0: {}
@@ -3622,6 +3808,8 @@ snapshots:
caniuse-lite@1.0.30001810: {}
+ chai@6.3.0: {}
+
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@@ -3765,6 +3953,8 @@ snapshots:
deep-is@0.1.4: {}
+ deepmerge@2.2.1: {}
+
define-data-property@1.1.4:
dependencies:
es-define-property: 1.0.1
@@ -3890,6 +4080,8 @@ snapshots:
iterator.prototype: 1.1.5
math-intrinsics: 1.1.0
+ es-module-lexer@2.3.2: {}
+
es-object-atoms@1.1.2:
dependencies:
es-errors: 1.3.0
@@ -4018,8 +4210,14 @@ snapshots:
estraverse@5.3.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.9
+
esutils@2.0.3: {}
+ expect-type@1.4.0: {}
+
fast-deep-equal@3.1.3: {}
fast-glob@3.3.3:
@@ -4084,6 +4282,20 @@ snapshots:
format@0.2.2: {}
+ formik@2.4.9(@types/react@18.3.31)(react@18.3.1):
+ dependencies:
+ '@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.31)
+ deepmerge: 2.2.1
+ hoist-non-react-statics: 3.3.2
+ lodash: 4.18.1
+ lodash-es: 4.18.1
+ react: 18.3.1
+ react-fast-compare: 2.0.4
+ tiny-warning: 1.0.3
+ tslib: 2.8.1
+ transitivePeerDependencies:
+ - '@types/react'
+
fraction.js@5.3.4: {}
fsevents@2.3.3:
@@ -4194,6 +4406,10 @@ snapshots:
highlightjs-vue@1.0.0: {}
+ hoist-non-react-statics@3.3.2:
+ dependencies:
+ react-is: 16.13.1
+
html-parse-stringify@4.0.1: {}
https-proxy-agent@5.0.1(supports-color@7.2.0):
@@ -4457,8 +4673,12 @@ snapshots:
dependencies:
p-locate: 5.0.0
+ lodash-es@4.18.1: {}
+
lodash.merge@4.6.2: {}
+ lodash@4.18.1: {}
+
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
@@ -4472,6 +4692,10 @@ snapshots:
dependencies:
react: 18.3.1
+ magic-string@1.4.3:
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -4556,6 +4780,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.2
+ obug@2.2.1: {}
+
oidc-client-ts@3.5.0:
dependencies:
jwt-decode: 4.0.0
@@ -4665,6 +4891,8 @@ snapshots:
object-assign: 4.1.1
react-is: 16.13.1
+ property-expr@2.0.6: {}
+
property-information@7.2.0: {}
proxy-from-env@2.1.0: {}
@@ -4679,6 +4907,8 @@ snapshots:
react: 18.3.1
scheduler: 0.23.2
+ react-fast-compare@2.0.4: {}
+
react-i18next@17.0.13(i18next@26.4.2(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.29.7
@@ -4925,6 +5155,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ std-env@4.3.0: {}
+
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@@ -5050,6 +5282,14 @@ snapshots:
dependencies:
any-promise: 1.3.0
+ tiny-case@1.0.3: {}
+
+ tiny-warning@1.0.3: {}
+
+ tinybench@6.2.0: {}
+
+ tinyexec@1.3.1: {}
+
tinyglobby@0.2.17:
dependencies:
fdir: 6.5.0(picomatch@4.0.5)
@@ -5059,6 +5299,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ toposort@2.0.2: {}
+
ts-api-utils@2.5.0(typescript@5.9.3):
dependencies:
typescript: 5.9.3
@@ -5071,6 +5313,8 @@ snapshots:
dependencies:
prelude-ls: 1.2.1
+ type-fest@2.19.0: {}
+
typed-array-buffer@1.0.3:
dependencies:
call-bound: 1.0.4
@@ -5169,6 +5413,27 @@ snapshots:
fsevents: 2.3.3
jiti: 1.21.7
+ vitest@5.0.3(@types/node@24.13.4)(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7)):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/mocker': 5.0.3(vite@8.3.0(@types/node@24.13.4)(jiti@1.21.7))
+ chai: 6.3.0
+ es-module-lexer: 2.3.2
+ expect-type: 1.4.0
+ magic-string: 1.4.3
+ obug: 2.2.1
+ picomatch: 4.0.7
+ std-env: 4.3.0
+ tinybench: 6.2.0
+ tinyexec: 1.3.1
+ tinyglobby: 0.2.17
+ vite: 8.3.0(@types/node@24.13.4)(jiti@1.21.7)
+ why-is-node-running: 3.2.1
+ optionalDependencies:
+ '@types/node': 24.13.4
+ transitivePeerDependencies:
+ - msw
+
which-boxed-primitive@1.1.1:
dependencies:
is-bigint: 1.1.0
@@ -5214,6 +5479,8 @@ snapshots:
dependencies:
isexe: 2.0.0
+ why-is-node-running@3.2.1: {}
+
word-wrap@1.2.5: {}
wrap-ansi@9.0.2:
@@ -5237,6 +5504,13 @@ snapshots:
yocto-queue@0.1.0: {}
+ yup@1.7.1:
+ dependencies:
+ property-expr: 2.0.6
+ tiny-case: 1.0.3
+ toposort: 2.0.2
+ type-fest: 2.19.0
+
zustand@4.5.7(@types/react@18.3.31)(react@18.3.1):
dependencies:
use-sync-external-store: 1.6.0(react@18.3.1)
diff --git a/src/locales/en.json b/src/locales/en.json
index 012220c..9d489c8 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -156,6 +156,9 @@
"value": "Value",
"optional": "optional",
"required": "required",
+ "invalid_tier": "must be a whole number between 1 and 2147483647",
+ "invalid_url_scheme": "must start with http://, https://, or oci://",
+ "invalid_duration": "must be a valid duration such as 30s, 5m or 1h30m",
"content_kind": "Kind",
"content_namespace": "Namespace",
"content_name": "Name",
diff --git a/src/modules/authentication/methods/OidcAuth.tsx b/src/modules/authentication/methods/OidcAuth.tsx
index afe11cb..948781b 100644
--- a/src/modules/authentication/methods/OidcAuth.tsx
+++ b/src/modules/authentication/methods/OidcAuth.tsx
@@ -1,17 +1,46 @@
+import { useState } from "react";
+import { AlertCircle, ShieldCheck } from "lucide-react";
import { Button } from "@/lib/components/ui/inputs/button";
-import { userManager } from "@/modules/authentication/oidc";
-import { ShieldCheck } from "lucide-react";
+import {
+ describeSigninError,
+ userManager,
+} from "@/modules/authentication/oidc";
export const OidcAuth = () => {
+ const [errorMessage, setErrorMessage] = useState("");
+
+ const startSignin = async () => {
+ setErrorMessage("");
+ try {
+ await userManager?.signinRedirect();
+ } catch (error) {
+ console.error("Could not start the OIDC sign-in", error);
+ setErrorMessage(describeSigninError(error));
+ }
+ };
+
return (
-
+
+
+ {errorMessage && (
+
+ )}
+
);
};
diff --git a/src/modules/authentication/oidc.ts b/src/modules/authentication/oidc.ts
index 44e0581..4b543fa 100644
--- a/src/modules/authentication/oidc.ts
+++ b/src/modules/authentication/oidc.ts
@@ -25,6 +25,16 @@ export const oidcCallbackPath = new URL(redirectUri, window.location.origin)
export const isOidcConfigured = Boolean(authority && clientId);
+/**
+ * Explains why the sign-in could not start. Starting it means fetching the provider's discovery
+ * document, so the usual causes are a provider the browser cannot reach and a TLS certificate it does
+ * not trust. Browsers report both only as a failed fetch.
+ */
+export const describeSigninError = (error: unknown): string => {
+ const detail = error instanceof Error ? error.message : String(error);
+ return `Could not start the sign-in with ${authority || "the OIDC provider"}: ${detail}. Check that the provider is reachable from this browser and that its TLS certificate is trusted.`;
+};
+
export const userManager = isOidcConfigured
? new UserManager({
authority: authority!,
diff --git a/src/modules/profiles/profile-form/ProfileForm.tsx b/src/modules/profiles/profile-form/ProfileForm.tsx
index 5ba70df..da2a07e 100644
--- a/src/modules/profiles/profile-form/ProfileForm.tsx
+++ b/src/modules/profiles/profile-form/ProfileForm.tsx
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
+import { useFormik } from "formik";
import { Plus, X } from "lucide-react";
import { Button } from "@/lib/components/ui/inputs/button";
@@ -20,160 +21,80 @@ import {
} from "@/lib/components/ui/inputs/toggle-group";
import TagInput from "@/lib/components/ui/data-display/TagInput";
import { useCreateProfile } from "@/modules/profiles/profile-form/hooks/useProfileMutations";
-import { CreateProfileRequest } from "@/types/profile.types";
-
-type ContentType = "helm" | "yaml" | "existing" | "remoteURL";
+import {
+ ContentType,
+ ProfileFormValues,
+ ProfileKind,
+ buildCreateRequest,
+ createProfileFormSchema,
+ initialProfileFormValues,
+ isRemoteHelmSource,
+} from "@/modules/profiles/profile-form/profileFormSchema";
-type SelectorRow = { key: string; value: string };
-
-const isRemoteHelmSource = (repositoryURL: string) => {
- const lower = repositoryURL.toLowerCase();
- return (
- lower.startsWith("http://") ||
- lower.startsWith("https://") ||
- lower.startsWith("oci://")
- );
-};
+const FieldError = ({ message }: { message?: string }) =>
+ message ? {message}
: null;
export const ProfileForm = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const createProfile = useCreateProfile();
- const [kind, setKind] = useState<"ClusterProfile" | "Profile">(
- "ClusterProfile",
- );
- const [namespace, setNamespace] = useState("");
- const [name, setName] = useState("");
- const [tier, setTier] = useState("");
- const [dependsOn, setDependsOn] = useState([]);
- const [selectorRows, setSelectorRows] = useState([
- { key: "", value: "" },
- ]);
- const [contentType, setContentType] = useState("helm");
-
- const [repositoryURL, setRepositoryURL] = useState("");
- const [repositoryName, setRepositoryName] = useState("");
- const [chartName, setChartName] = useState("");
- const [chartVersion, setChartVersion] = useState("");
- const [releaseName, setReleaseName] = useState("");
- const [releaseNamespace, setReleaseNamespace] = useState("");
- const [values, setValues] = useState("");
+ // Errors returned by the backend. Field errors come from the Yup schema.
+ const [serverError, setServerError] = useState(null);
- const [yaml, setYaml] = useState("");
+ const formik = useFormik({
+ initialValues: initialProfileFormValues,
+ validationSchema: createProfileFormSchema(t),
+ onSubmit: (values) => {
+ setServerError(null);
+ createProfile.mutate(buildCreateRequest(values), {
+ onSuccess: () => navigate("/sveltos/profiles"),
+ onError: (mutationError) => setServerError(mutationError.message),
+ });
+ },
+ });
- const [existingKind, setExistingKind] = useState("ConfigMap");
- const [existingNamespace, setExistingNamespace] = useState("");
- const [existingName, setExistingName] = useState("");
+ const { values } = formik;
- const [remoteURL, setRemoteURL] = useState("");
- const [remoteURLInterval, setRemoteURLInterval] = useState("");
- const [remoteURLTemplate, setRemoteURLTemplate] = useState(false);
- const [remoteURLInsecureSkipTLSVerify, setRemoteURLInsecureSkipTLSVerify] =
- useState(false);
- const [remoteURLPlainHTTP, setRemoteURLPlainHTTP] = useState(false);
- const [remoteURLSecretNamespace, setRemoteURLSecretNamespace] = useState("");
- const [remoteURLSecretName, setRemoteURLSecretName] = useState("");
+ // A field's error is shown once the user has left it or tried to submit.
+ const errorFor = (field: keyof ProfileFormValues) => {
+ const error = formik.errors[field];
+ const showError = formik.touched[field] || formik.submitCount > 0;
+ return showError && typeof error === "string" ? error : undefined;
+ };
- const [error, setError] = useState(null);
+ // repositoryName, chartName and chartVersion are only optional for Flux sources.
+ const isHelmChartRemote =
+ values.contentType === "helm" && isRemoteHelmSource(values.repositoryURL);
+ const optionalUnlessRemote = isHelmChartRemote
+ ? ""
+ : ` (${t("common.optional")})`;
const updateSelectorRow = (
index: number,
field: "key" | "value",
fieldValue: string,
) => {
- setSelectorRows(
- selectorRows.map((row, i) =>
+ formik.setFieldValue(
+ "selectorRows",
+ values.selectorRows.map((row, i) =>
i === index ? { ...row, [field]: fieldValue } : row,
),
);
};
const removeSelectorRow = (index: number) => {
- setSelectorRows(selectorRows.filter((_, i) => i !== index));
+ formik.setFieldValue(
+ "selectorRows",
+ values.selectorRows.filter((_, i) => i !== index),
+ );
};
- const handleSubmit = () => {
- setError(null);
-
- if (
- contentType === "helm" &&
- isRemoteHelmSource(repositoryURL) &&
- !repositoryName
- ) {
- setError(
- `${t("common.repository_name")} ${t("common.required")}: ${t("common.repository_url")} ${repositoryURL}`,
- );
- return;
- }
-
- if (contentType === "remoteURL") {
- if (!remoteURL) {
- setError(`${t("common.url")} ${t("common.required")}`);
- return;
- }
- if (
- (remoteURLSecretNamespace && !remoteURLSecretName) ||
- (!remoteURLSecretNamespace && remoteURLSecretName)
- ) {
- setError(
- `${t("common.remote_url_secret_ref")} ${t("common.content_namespace")} + ${t("common.content_name")}: ${t("common.required")}`,
- );
- return;
- }
- }
-
- const clusterSelector: { [key: string]: string } = {};
- selectorRows.forEach((row) => {
- if (row.key) {
- clusterSelector[row.key] = row.value;
- }
- });
-
- const request: CreateProfileRequest = {
- kind,
- namespace: kind === "Profile" ? namespace : undefined,
- name,
- clusterSelector,
- tier: tier ? Number(tier) : undefined,
- dependsOn: dependsOn.length > 0 ? dependsOn : undefined,
- };
-
- if (contentType === "helm") {
- request.helmChart = {
- repositoryURL,
- repositoryName: repositoryName || undefined,
- chartName: chartName || undefined,
- chartVersion: chartVersion || undefined,
- releaseName,
- releaseNamespace,
- values: values || undefined,
- };
- } else if (contentType === "yaml") {
- request.yaml = yaml;
- } else if (contentType === "existing") {
- request.existingContent = {
- kind: existingKind,
- namespace: existingNamespace,
- name: existingName,
- };
- } else {
- request.remoteURL = {
- url: remoteURL,
- interval: remoteURLInterval || undefined,
- secretRef: remoteURLSecretName
- ? { namespace: remoteURLSecretNamespace, name: remoteURLSecretName }
- : undefined,
- template: remoteURLTemplate || undefined,
- insecureSkipTLSVerify: remoteURLInsecureSkipTLSVerify || undefined,
- plainHTTP: remoteURLPlainHTTP || undefined,
- };
- }
-
- createProfile.mutate(request, {
- onSuccess: () => navigate("/sveltos/profiles"),
- onError: (mutationError) => setError(mutationError.message),
- });
+ const addSelectorRow = () => {
+ formik.setFieldValue("selectorRows", [
+ ...values.selectorRows,
+ { key: "", value: "" },
+ ]);
};
return (
@@ -183,10 +104,10 @@ export const ProfileForm = () => {
{
if (newKind) {
- setKind(newKind as "ClusterProfile" | "Profile");
+ formik.setFieldValue("kind", newKind as ProfileKind);
}
}}
className="justify-start bg-muted p-1 rounded-md inline-flex w-fit"
@@ -197,7 +118,7 @@ export const ProfileForm = () => {
Profile
- {kind === "Profile"
+ {values.kind === "Profile"
? t("common.kind_hint_profile")
: t("common.kind_hint_cluster_profile")}
@@ -206,15 +127,14 @@ export const ProfileForm = () => {
@@ -224,19 +144,16 @@ export const ProfileForm = () => {
-
setTier(e.target.value)}
- />
+
+
formik.setFieldValue("dependsOn", tags)}
placeholder=""
/>
@@ -249,7 +166,7 @@ export const ProfileForm = () => {
{t("common.cluster_selector")}
- {selectorRows.map((row, index) => (
+ {values.selectorRows.map((row, index) => (
{
))}
-