An encrypted personal cloud built on the AT Protocol.

Add ESLint, Prettier, and Immer to web frontend

ESLint 10 flat config (eslint.config.ts) with typescript-eslint strict
type-checked, react-hooks, jsx-a11y, eslint-plugin-functional for
immutability enforcement, eslint-plugin-security, no-secrets, and
sonarjs. Prettier with no-semicolons, double quotes, Tailwind class
sorting. Immer middleware on auth Zustand store with enableMapSet and
enableArrayMethods plugins. Upgraded all Phosphor icon imports to v2
non-deprecated names. Added Readonly<> props on all components and
readonly modifiers on storage type interfaces.

[CL-1]

sans-self.org 82bf779e 41919967

Waiting for spindle ...
+2327 -1597
+3
web/.prettierignore
··· 1 + src/routeTree.gen.ts 2 + src/wasm/ 3 + dist/
+7
web/.prettierrc
··· 1 + { 2 + "semi": false, 3 + "singleQuote": false, 4 + "printWidth": 100, 5 + "trailingComma": "all", 6 + "plugins": ["prettier-plugin-tailwindcss"] 7 + }
+439 -2
web/bun.lock
··· 9 9 "@tanstack/react-router": "^1.163.3", 10 10 "comlink": "^4.4.2", 11 11 "dexie": "^4.3.0", 12 + "immer": "^11.1.4", 12 13 "react": "^19.2.4", 13 14 "react-dom": "^19.2.4", 14 15 "zustand": "^5.0.11", ··· 22 23 "@types/react-dom": "^19.2.3", 23 24 "@vitejs/plugin-react": "^5.1.4", 24 25 "daisyui": "^5.5.19", 26 + "eslint": "^10.0.3", 27 + "eslint-config-prettier": "^10.1.8", 28 + "eslint-plugin-functional": "^9.0.4", 29 + "eslint-plugin-jsx-a11y": "^6.10.2", 30 + "eslint-plugin-no-secrets": "^2.3.3", 31 + "eslint-plugin-react-hooks": "^7.0.1", 32 + "eslint-plugin-security": "^4.0.0", 33 + "eslint-plugin-sonarjs": "^4.0.1", 25 34 "fake-indexeddb": "^6.2.5", 26 35 "happy-dom": "^20.8.3", 36 + "jiti": "^2.6.1", 37 + "prettier": "^3.8.1", 38 + "prettier-plugin-tailwindcss": "^0.7.2", 27 39 "tailwindcss": "^4.2.1", 28 40 "typescript": "^5.9.3", 41 + "typescript-eslint": "^8.56.1", 29 42 "vite": "^7.3.1", 30 43 "vite-plugin-comlink": "^5.3.0", 31 44 "vite-plugin-wasm": "^3.5.0", ··· 132 145 133 146 "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], 134 147 148 + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], 149 + 150 + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], 151 + 152 + "@eslint/config-array": ["@eslint/config-array@0.23.3", "", { "dependencies": { "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw=="], 153 + 154 + "@eslint/config-helpers": ["@eslint/config-helpers@0.5.3", "", { "dependencies": { "@eslint/core": "^1.1.1" } }, "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw=="], 155 + 156 + "@eslint/core": ["@eslint/core@1.1.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ=="], 157 + 158 + "@eslint/object-schema": ["@eslint/object-schema@3.0.3", "", {}, "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ=="], 159 + 160 + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.6.1", "", { "dependencies": { "@eslint/core": "^1.1.1", "levn": "^0.4.1" } }, "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ=="], 161 + 162 + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], 163 + 164 + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], 165 + 166 + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], 167 + 168 + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], 169 + 135 170 "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], 136 171 137 172 "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], ··· 266 301 267 302 "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], 268 303 304 + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], 305 + 269 306 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 307 + 308 + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], 270 309 271 310 "@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="], 272 311 ··· 278 317 279 318 "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], 280 319 320 + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], 321 + 322 + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], 323 + 324 + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], 325 + 326 + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], 327 + 328 + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], 329 + 330 + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], 331 + 332 + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], 333 + 334 + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], 335 + 336 + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], 337 + 338 + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], 339 + 281 340 "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], 282 341 283 342 "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], ··· 295 354 "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], 296 355 297 356 "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], 357 + 358 + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], 359 + 360 + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], 298 361 299 362 "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], 300 363 ··· 306 369 307 370 "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], 308 371 372 + "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], 373 + 374 + "array-includes": ["array-includes@3.1.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.4", "define-properties": "^1.2.1", "es-abstract": "^1.24.0", "es-object-atoms": "^1.1.1", "get-intrinsic": "^1.3.0", "is-string": "^1.1.1", "math-intrinsics": "^1.1.0" } }, "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ=="], 375 + 376 + "array.prototype.flat": ["array.prototype.flat@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg=="], 377 + 378 + "array.prototype.flatmap": ["array.prototype.flatmap@1.3.3", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-shim-unscopables": "^1.0.2" } }, "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg=="], 379 + 380 + "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], 381 + 309 382 "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], 310 383 311 384 "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], 312 385 386 + "ast-types-flow": ["ast-types-flow@0.0.8", "", {}, "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ=="], 387 + 388 + "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], 389 + 390 + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], 391 + 392 + "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], 393 + 394 + "axobject-query": ["axobject-query@4.1.0", "", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="], 395 + 313 396 "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="], 314 397 398 + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], 399 + 315 400 "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], 316 401 317 402 "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], 318 403 404 + "brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], 405 + 319 406 "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], 320 407 321 408 "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], 322 409 410 + "builtin-modules": ["builtin-modules@3.3.0", "", {}, "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw=="], 411 + 412 + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], 413 + 414 + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], 415 + 416 + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], 417 + 418 + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], 419 + 323 420 "caniuse-lite": ["caniuse-lite@1.0.30001776", "", {}, "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw=="], 324 421 325 422 "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], ··· 328 425 329 426 "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], 330 427 428 + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], 429 + 331 430 "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], 332 431 333 432 "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], 334 433 434 + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], 435 + 335 436 "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], 336 437 337 438 "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], 338 439 339 440 "daisyui": ["daisyui@5.5.19", "", {}, "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA=="], 340 441 442 + "damerau-levenshtein": ["damerau-levenshtein@1.0.8", "", {}, "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA=="], 443 + 444 + "data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="], 445 + 446 + "data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="], 447 + 448 + "data-view-byte-offset": ["data-view-byte-offset@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-data-view": "^1.0.1" } }, "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ=="], 449 + 341 450 "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], 342 451 452 + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], 453 + 454 + "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], 455 + 456 + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], 457 + 458 + "define-properties": ["define-properties@1.2.1", "", { "dependencies": { "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" } }, "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg=="], 459 + 343 460 "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], 344 461 345 462 "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], ··· 350 467 351 468 "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], 352 469 470 + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], 471 + 353 472 "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], 354 473 474 + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], 475 + 355 476 "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], 356 477 357 478 "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], 358 479 480 + "es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="], 481 + 482 + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], 483 + 484 + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], 485 + 359 486 "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], 360 487 488 + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], 489 + 490 + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], 491 + 492 + "es-shim-unscopables": ["es-shim-unscopables@1.1.0", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw=="], 493 + 494 + "es-to-primitive": ["es-to-primitive@1.3.0", "", { "dependencies": { "is-callable": "^1.2.7", "is-date-object": "^1.0.5", "is-symbol": "^1.0.4" } }, "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g=="], 495 + 361 496 "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], 362 497 363 498 "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], 364 499 500 + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], 501 + 502 + "eslint": ["eslint@10.0.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.3", "@eslint/config-helpers": "^0.5.2", "@eslint/core": "^1.1.1", "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.1.1", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ=="], 503 + 504 + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], 505 + 506 + "eslint-plugin-functional": ["eslint-plugin-functional@9.0.4", "", { "dependencies": { "@typescript-eslint/utils": "^8.26.0", "deepmerge-ts": "^7.1.5", "escape-string-regexp": "^5.0.0", "is-immutable-type": "^5.0.1", "ts-api-utils": "^2.0.1", "ts-declaration-location": "^1.0.6" }, "peerDependencies": { "eslint": "^9.0.0 || ^10.0.0", "typescript": ">=4.7.4" }, "optionalPeers": ["typescript"] }, "sha512-zm4qaoqb2r50V4WXxt0Mj92buXGMECYvMxGQ6sSb+XeJ+Eec6zCHuMY2+AWK1mqiApvUz2tCtp1P3zcEPU0huw=="], 507 + 508 + "eslint-plugin-jsx-a11y": ["eslint-plugin-jsx-a11y@6.10.2", "", { "dependencies": { "aria-query": "^5.3.2", "array-includes": "^3.1.8", "array.prototype.flatmap": "^1.3.2", "ast-types-flow": "^0.0.8", "axe-core": "^4.10.0", "axobject-query": "^4.1.0", "damerau-levenshtein": "^1.0.8", "emoji-regex": "^9.2.2", "hasown": "^2.0.2", "jsx-ast-utils": "^3.3.5", "language-tags": "^1.0.9", "minimatch": "^3.1.2", "object.fromentries": "^2.0.8", "safe-regex-test": "^1.0.3", "string.prototype.includes": "^2.0.1" }, "peerDependencies": { "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q=="], 509 + 510 + "eslint-plugin-no-secrets": ["eslint-plugin-no-secrets@2.3.3", "", { "peerDependencies": { "eslint": ">=5" } }, "sha512-sroCsCscpwQxZ/O9Ml69w5qt/2nvHhXDeyUnSqSC5dMANGxt4rQgUn7xbPvDmhbMPmUCAc2Y3SRU0cXet7kNWQ=="], 511 + 512 + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], 513 + 514 + "eslint-plugin-security": ["eslint-plugin-security@4.0.0", "", { "dependencies": { "safe-regex": "^2.1.1" } }, "sha512-tfuQT8K/Li1ZxhFzyD8wPIKtlzZxqBcPr9q0jFMQ77wWAbKBVEhaMPVQRTMTvCMUDhwBe5vPVqQPwAGk/ASfxQ=="], 515 + 516 + "eslint-plugin-sonarjs": ["eslint-plugin-sonarjs@4.0.1", "", { "dependencies": { "@eslint-community/regexpp": "4.12.2", "builtin-modules": "3.3.0", "bytes": "3.1.2", "functional-red-black-tree": "1.0.1", "globals": "17.4.0", "jsx-ast-utils-x": "0.1.0", "lodash.merge": "4.6.2", "minimatch": "10.2.4", "scslre": "0.3.0", "semver": "7.7.4", "ts-api-utils": "2.4.0", "typescript": ">=5" }, "peerDependencies": { "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" } }, "sha512-lmqzFTrw0/zpHQMRmwdgdEEw50s3md0c8RE23JqNom9ovsGQxC/azZ9H00aGKVDkxIXywfcxwzyFJ9Sm3bp2ng=="], 517 + 518 + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], 519 + 520 + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], 521 + 522 + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], 523 + 365 524 "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], 366 525 526 + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], 527 + 528 + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], 529 + 530 + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], 531 + 367 532 "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], 368 533 534 + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], 535 + 369 536 "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], 370 537 371 538 "fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="], 539 + 540 + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], 541 + 542 + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], 543 + 544 + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], 372 545 373 546 "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], 374 547 548 + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], 549 + 375 550 "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], 376 551 552 + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], 553 + 554 + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], 555 + 556 + "flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="], 557 + 558 + "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], 559 + 377 560 "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 378 561 562 + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], 563 + 564 + "function.prototype.name": ["function.prototype.name@1.1.8", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "functions-have-names": "^1.2.3", "hasown": "^2.0.2", "is-callable": "^1.2.7" } }, "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q=="], 565 + 566 + "functional-red-black-tree": ["functional-red-black-tree@1.0.1", "", {}, "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g=="], 567 + 568 + "functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="], 569 + 570 + "generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="], 571 + 379 572 "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], 380 573 574 + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], 575 + 576 + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], 577 + 578 + "get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="], 579 + 381 580 "get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="], 382 581 383 - "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 582 + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], 583 + 584 + "globals": ["globals@17.4.0", "", {}, "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw=="], 585 + 586 + "globalthis": ["globalthis@1.0.4", "", { "dependencies": { "define-properties": "^1.2.1", "gopd": "^1.0.1" } }, "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ=="], 587 + 588 + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], 384 589 385 590 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], 386 591 387 592 "happy-dom": ["happy-dom@20.8.3", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-lMHQRRwIPyJ70HV0kkFT7jH/gXzSI7yDkQFe07E2flwmNDFoWUTRMKpW2sglsnpeA7b6S2TJPp98EbQxai8eaQ=="], 388 593 594 + "has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="], 595 + 596 + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], 597 + 598 + "has-proto": ["has-proto@1.2.0", "", { "dependencies": { "dunder-proto": "^1.0.0" } }, "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ=="], 599 + 600 + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], 601 + 602 + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], 603 + 604 + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], 605 + 606 + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], 607 + 608 + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], 609 + 610 + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], 611 + 612 + "immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], 613 + 614 + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], 615 + 389 616 "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], 390 617 618 + "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], 619 + 620 + "is-array-buffer": ["is-array-buffer@3.0.5", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A=="], 621 + 622 + "is-async-function": ["is-async-function@2.1.1", "", { "dependencies": { "async-function": "^1.0.0", "call-bound": "^1.0.3", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ=="], 623 + 624 + "is-bigint": ["is-bigint@1.1.0", "", { "dependencies": { "has-bigints": "^1.0.2" } }, "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ=="], 625 + 391 626 "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], 392 627 628 + "is-boolean-object": ["is-boolean-object@1.2.2", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A=="], 629 + 630 + "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], 631 + 632 + "is-data-view": ["is-data-view@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "is-typed-array": "^1.1.13" } }, "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw=="], 633 + 634 + "is-date-object": ["is-date-object@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg=="], 635 + 393 636 "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], 637 + 638 + "is-finalizationregistry": ["is-finalizationregistry@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg=="], 639 + 640 + "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], 394 641 395 642 "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], 396 643 644 + "is-immutable-type": ["is-immutable-type@5.0.1", "", { "dependencies": { "@typescript-eslint/type-utils": "^8.0.0", "ts-api-utils": "^2.0.0", "ts-declaration-location": "^1.0.4" }, "peerDependencies": { "eslint": "*", "typescript": ">=4.7.4" } }, "sha512-LkHEOGVZZXxGl8vDs+10k3DvP++SEoYEAJLRk6buTFi6kD7QekThV7xHS0j6gpnUCQ0zpud/gMDGiV4dQneLTg=="], 645 + 646 + "is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="], 647 + 648 + "is-negative-zero": ["is-negative-zero@2.0.3", "", {}, "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw=="], 649 + 397 650 "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], 398 651 652 + "is-number-object": ["is-number-object@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw=="], 653 + 654 + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], 655 + 656 + "is-set": ["is-set@2.0.3", "", {}, "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg=="], 657 + 658 + "is-shared-array-buffer": ["is-shared-array-buffer@1.0.4", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A=="], 659 + 660 + "is-string": ["is-string@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3", "has-tostringtag": "^1.0.2" } }, "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA=="], 661 + 662 + "is-symbol": ["is-symbol@1.1.1", "", { "dependencies": { "call-bound": "^1.0.2", "has-symbols": "^1.1.0", "safe-regex-test": "^1.1.0" } }, "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w=="], 663 + 664 + "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], 665 + 666 + "is-weakmap": ["is-weakmap@2.0.2", "", {}, "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w=="], 667 + 668 + "is-weakref": ["is-weakref@1.1.1", "", { "dependencies": { "call-bound": "^1.0.3" } }, "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew=="], 669 + 670 + "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], 671 + 672 + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], 673 + 399 674 "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], 675 + 676 + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], 400 677 401 678 "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], 402 679 ··· 404 681 405 682 "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], 406 683 684 + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], 685 + 686 + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], 687 + 688 + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], 689 + 407 690 "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], 408 691 692 + "jsx-ast-utils": ["jsx-ast-utils@3.3.5", "", { "dependencies": { "array-includes": "^3.1.6", "array.prototype.flat": "^1.3.1", "object.assign": "^4.1.4", "object.values": "^1.1.6" } }, "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ=="], 693 + 694 + "jsx-ast-utils-x": ["jsx-ast-utils-x@0.1.0", "", {}, "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw=="], 695 + 696 + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], 697 + 698 + "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], 699 + 700 + "language-tags": ["language-tags@1.0.9", "", { "dependencies": { "language-subtag-registry": "^0.3.20" } }, "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA=="], 701 + 702 + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], 703 + 409 704 "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], 410 705 411 706 "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], ··· 430 725 431 726 "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], 432 727 728 + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], 729 + 730 + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], 731 + 433 732 "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], 434 733 435 734 "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], 436 735 437 736 "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], 438 737 738 + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], 739 + 439 740 "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], 440 741 742 + "minimatch": ["minimatch@10.2.4", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg=="], 743 + 441 744 "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], 442 745 443 746 "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], 444 747 748 + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], 749 + 445 750 "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], 446 751 447 752 "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], 448 753 754 + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], 755 + 756 + "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], 757 + 758 + "object.assign": ["object.assign@4.1.7", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0", "has-symbols": "^1.1.0", "object-keys": "^1.1.1" } }, "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw=="], 759 + 760 + "object.fromentries": ["object.fromentries@2.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.2", "es-object-atoms": "^1.0.0" } }, "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ=="], 761 + 762 + "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], 763 + 449 764 "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], 450 765 766 + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], 767 + 768 + "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], 769 + 770 + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], 771 + 772 + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], 773 + 774 + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], 775 + 776 + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], 777 + 451 778 "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], 452 779 453 780 "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], 454 781 455 782 "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], 783 + 784 + "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], 456 785 457 786 "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], 458 787 788 + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], 789 + 459 790 "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], 460 791 792 + "prettier-plugin-tailwindcss": ["prettier-plugin-tailwindcss@0.7.2", "", { "peerDependencies": { "@ianvs/prettier-plugin-sort-imports": "*", "@prettier/plugin-hermes": "*", "@prettier/plugin-oxc": "*", "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", "@zackad/prettier-plugin-twig": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", "prettier-plugin-svelte": "*" }, "optionalPeers": ["@ianvs/prettier-plugin-sort-imports", "@prettier/plugin-hermes", "@prettier/plugin-oxc", "@prettier/plugin-pug", "@shopify/prettier-plugin-liquid", "@trivago/prettier-plugin-sort-imports", "@zackad/prettier-plugin-twig", "prettier-plugin-astro", "prettier-plugin-css-order", "prettier-plugin-jsdoc", "prettier-plugin-marko", "prettier-plugin-multiline-arrays", "prettier-plugin-organize-attributes", "prettier-plugin-organize-imports", "prettier-plugin-sort-imports", "prettier-plugin-svelte"] }, "sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA=="], 793 + 461 794 "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], 795 + 796 + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], 462 797 463 798 "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], 464 799 ··· 474 809 475 810 "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], 476 811 812 + "refa": ["refa@0.12.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0" } }, "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g=="], 813 + 814 + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], 815 + 816 + "regexp-ast-analysis": ["regexp-ast-analysis@0.7.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.1" } }, "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A=="], 817 + 818 + "regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="], 819 + 820 + "regexp.prototype.flags": ["regexp.prototype.flags@1.5.4", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-errors": "^1.3.0", "get-proto": "^1.0.1", "gopd": "^1.2.0", "set-function-name": "^2.0.2" } }, "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA=="], 821 + 477 822 "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], 478 823 479 824 "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], 480 825 826 + "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "get-intrinsic": "^1.2.6", "has-symbols": "^1.1.0", "isarray": "^2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], 827 + 828 + "safe-push-apply": ["safe-push-apply@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "isarray": "^2.0.5" } }, "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA=="], 829 + 830 + "safe-regex": ["safe-regex@2.1.1", "", { "dependencies": { "regexp-tree": "~0.1.1" } }, "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A=="], 831 + 832 + "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], 833 + 481 834 "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], 482 835 483 - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 836 + "scslre": ["scslre@0.3.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.8.0", "refa": "^0.12.0", "regexp-ast-analysis": "^0.7.0" } }, "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ=="], 837 + 838 + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], 484 839 485 840 "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], 486 841 487 842 "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], 488 843 844 + "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="], 845 + 846 + "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="], 847 + 848 + "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], 849 + 850 + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], 851 + 852 + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], 853 + 854 + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], 855 + 856 + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], 857 + 858 + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], 859 + 860 + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], 861 + 489 862 "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], 490 863 491 864 "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], ··· 496 869 497 870 "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], 498 871 872 + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], 873 + 874 + "string.prototype.includes": ["string.prototype.includes@2.0.1", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-abstract": "^1.23.3" } }, "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg=="], 875 + 876 + "string.prototype.trim": ["string.prototype.trim@1.2.10", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-data-property": "^1.1.4", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-object-atoms": "^1.0.0", "has-property-descriptors": "^1.0.2" } }, "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA=="], 877 + 878 + "string.prototype.trimend": ["string.prototype.trimend@1.0.9", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.2", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ=="], 879 + 880 + "string.prototype.trimstart": ["string.prototype.trimstart@1.0.8", "", { "dependencies": { "call-bind": "^1.0.7", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg=="], 881 + 499 882 "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], 500 883 501 884 "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], ··· 516 899 517 900 "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], 518 901 902 + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], 903 + 904 + "ts-declaration-location": ["ts-declaration-location@1.0.7", "", { "dependencies": { "picomatch": "^4.0.2" }, "peerDependencies": { "typescript": ">=4.0.0" } }, "sha512-EDyGAwH1gO0Ausm9gV6T2nUvBgXT5kGoCMJPllOaooZ+4VvJiKBdZE7wK18N1deEowhcUptS+5GXZK8U/fvpwA=="], 905 + 519 906 "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], 520 907 521 908 "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], 522 909 910 + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], 911 + 912 + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], 913 + 914 + "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], 915 + 916 + "typed-array-byte-offset": ["typed-array-byte-offset@1.0.4", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.15", "reflect.getprototypeof": "^1.0.9" } }, "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ=="], 917 + 918 + "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], 919 + 523 920 "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], 524 921 922 + "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], 923 + 924 + "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], 925 + 525 926 "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], 526 927 527 928 "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], 528 929 529 930 "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], 931 + 932 + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], 530 933 531 934 "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], 532 935 ··· 542 945 543 946 "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], 544 947 948 + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], 949 + 950 + "which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="], 951 + 952 + "which-builtin-type": ["which-builtin-type@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "function.prototype.name": "^1.1.6", "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.1.0", "is-finalizationregistry": "^1.1.0", "is-generator-function": "^1.0.10", "is-regex": "^1.2.1", "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.1.0", "which-collection": "^1.0.2", "which-typed-array": "^1.1.16" } }, "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q=="], 953 + 954 + "which-collection": ["which-collection@1.0.2", "", { "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", "is-weakmap": "^2.0.2", "is-weakset": "^2.0.3" } }, "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw=="], 955 + 956 + "which-typed-array": ["which-typed-array@1.1.20", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "for-each": "^0.3.5", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" } }, "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg=="], 957 + 545 958 "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], 546 959 960 + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], 961 + 547 962 "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], 548 963 549 964 "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], 965 + 966 + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 550 967 551 968 "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], 552 969 970 + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], 971 + 553 972 "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], 554 973 974 + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 975 + 976 + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], 977 + 978 + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], 979 + 555 980 "@tailwindcss/node/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 556 981 557 982 "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], ··· 570 995 571 996 "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], 572 997 998 + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], 999 + 573 1000 "@vitest/mocker/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 574 1001 575 1002 "@vitest/snapshot/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 576 1003 577 1004 "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 578 1005 1006 + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], 1007 + 1008 + "eslint-plugin-functional/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], 1009 + 1010 + "eslint-plugin-jsx-a11y/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], 1011 + 579 1012 "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], 580 1013 581 1014 "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], 582 1015 583 1016 "vitest/magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], 1017 + 1018 + "eslint-plugin-jsx-a11y/minimatch/brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], 1019 + 1020 + "eslint-plugin-jsx-a11y/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], 584 1021 } 585 1022 }
+175
web/eslint.config.ts
··· 1 + import tseslint from "typescript-eslint" 2 + import reactHooks from "eslint-plugin-react-hooks" 3 + import jsxA11y from "eslint-plugin-jsx-a11y" 4 + import functional from "eslint-plugin-functional" 5 + import security from "eslint-plugin-security" 6 + import noSecrets from "eslint-plugin-no-secrets" 7 + import sonarjs from "eslint-plugin-sonarjs" 8 + import prettier from "eslint-config-prettier" 9 + 10 + export default tseslint.config( 11 + // --------------------------------------------------------------------------- 12 + // Global ignores 13 + // --------------------------------------------------------------------------- 14 + { 15 + ignores: [ 16 + "src/routeTree.gen.ts", 17 + "src/wasm/**", 18 + "dist/**", 19 + "node_modules/**", 20 + ], 21 + }, 22 + 23 + // --------------------------------------------------------------------------- 24 + // TypeScript — strict + type-checked (includes eslint:recommended overrides) 25 + // --------------------------------------------------------------------------- 26 + ...tseslint.configs.strictTypeChecked, 27 + ...tseslint.configs.stylisticTypeChecked, 28 + { 29 + languageOptions: { 30 + parserOptions: { 31 + projectService: true, 32 + tsconfigRootDir: import.meta.dirname, 33 + }, 34 + }, 35 + }, 36 + 37 + // --------------------------------------------------------------------------- 38 + // React 39 + // --------------------------------------------------------------------------- 40 + reactHooks.configs.flat.recommended, 41 + jsxA11y.flatConfigs.recommended, 42 + 43 + // --------------------------------------------------------------------------- 44 + // Functional — immutability enforcement 45 + // --------------------------------------------------------------------------- 46 + { 47 + plugins: { functional: functional }, 48 + rules: { 49 + "functional/immutable-data": "error", 50 + "functional/no-let": "error", 51 + "functional/no-loop-statements": "error", 52 + "functional/prefer-immutable-types": ["error", { 53 + enforcement: "None", 54 + ignoreInferredTypes: true, 55 + parameters: { enforcement: "None" }, 56 + returnTypes: { enforcement: "None" }, 57 + variables: { enforcement: "ReadonlyShallow" }, 58 + }], 59 + }, 60 + }, 61 + 62 + // --------------------------------------------------------------------------- 63 + // Security 64 + // --------------------------------------------------------------------------- 65 + security.configs.recommended, 66 + sonarjs.configs.recommended, 67 + { 68 + plugins: { "no-secrets": noSecrets }, 69 + rules: { 70 + "no-secrets/no-secrets": "error", 71 + }, 72 + }, 73 + 74 + // --------------------------------------------------------------------------- 75 + // Prettier — must be LAST (disables conflicting format rules) 76 + // --------------------------------------------------------------------------- 77 + prettier, 78 + 79 + // --------------------------------------------------------------------------- 80 + // Project-wide rule tuning 81 + // --------------------------------------------------------------------------- 82 + { 83 + rules: { 84 + // Allow void for fire-and-forget promises (e.g. `void store.boot()`) 85 + "@typescript-eslint/no-confusing-void-expression": "off", 86 + // Console is acceptable in a client app with dev tooling 87 + "no-console": "off", 88 + // Allow numbers in template literals — common and safe 89 + "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }], 90 + // Optional chains are safer than non-null assertions 91 + "@typescript-eslint/non-nullable-type-assertion-style": "off", 92 + 93 + // --- sonarjs dedup: disable rules that overlap with typescript-eslint --- 94 + "sonarjs/no-unused-vars": "off", 95 + "sonarjs/no-dead-store": "off", 96 + "sonarjs/deprecation": "off", 97 + // TODOs are intentional markers referencing issue numbers 98 + "sonarjs/todo-tag": "off", 99 + 100 + // High false-positive rate with bracket notation on typed objects 101 + "security/detect-object-injection": "off", 102 + 103 + // --- sonarjs: disable rules that don't suit React codebases --- 104 + // Nested template literals are standard in Tailwind className expressions 105 + "sonarjs/no-nested-template-literals": "off", 106 + // React components routinely define callbacks/handlers as nested functions 107 + "sonarjs/no-nested-functions": "off", 108 + // JSX uses ternaries extensively for conditional rendering 109 + "sonarjs/no-nested-conditional": "off", 110 + 111 + // Allow local object construction patterns (headers, records) and browser APIs 112 + "functional/immutable-data": ["error", { 113 + ignoreClasses: true, 114 + ignoreImmediateMutation: true, 115 + ignoreNonConstDeclarations: true, 116 + ignoreAccessorPattern: ["window.**", "document.**", "**.current"], 117 + }], 118 + // Raise entropy threshold to avoid false positives on charsets/alphabets 119 + "no-secrets/no-secrets": ["error", { tolerance: 5.5 }], 120 + }, 121 + }, 122 + 123 + // --------------------------------------------------------------------------- 124 + // Overrides: networking files (retry/nonce patterns need let + mutation) 125 + // --------------------------------------------------------------------------- 126 + { 127 + files: ["src/lib/api.ts", "src/lib/oauth.ts"], 128 + rules: { 129 + "functional/no-let": "off", 130 + "functional/immutable-data": "off", 131 + }, 132 + }, 133 + 134 + // --------------------------------------------------------------------------- 135 + // Overrides: encoding utilities (imperative byte manipulation) 136 + // --------------------------------------------------------------------------- 137 + { 138 + files: ["src/lib/encoding.ts"], 139 + rules: { 140 + "functional/no-let": "off", 141 + "functional/no-loop-statements": "off", 142 + "functional/immutable-data": "off", 143 + }, 144 + }, 145 + 146 + // --------------------------------------------------------------------------- 147 + // Overrides: route files 148 + // --------------------------------------------------------------------------- 149 + { 150 + files: ["src/routes/**/*.tsx"], 151 + rules: { 152 + // TanStack Router requires default exports 153 + "import/no-default-export": "off", 154 + // Zustand store methods accessed via useStore(s => s.method) are safe 155 + "@typescript-eslint/unbound-method": "off", 156 + // TanStack Router's beforeLoad guards use `throw redirect(...)` which 157 + // returns a Response, not an Error. This is the documented API pattern. 158 + "@typescript-eslint/only-throw-error": "off", 159 + }, 160 + }, 161 + 162 + // --------------------------------------------------------------------------- 163 + // Overrides: test files 164 + // --------------------------------------------------------------------------- 165 + { 166 + files: ["tests/**/*.ts", "tests/**/*.tsx"], 167 + rules: { 168 + "functional/no-let": "off", 169 + "functional/immutable-data": "off", 170 + "@typescript-eslint/no-unsafe-assignment": "off", 171 + "@typescript-eslint/no-unsafe-member-access": "off", 172 + "@typescript-eslint/no-unsafe-call": "off", 173 + }, 174 + }, 175 + )
+18 -1
web/package.json
··· 10 10 "build": "bun run wasm:build && tsc && vite build", 11 11 "preview": "vite preview", 12 12 "test": "vitest run", 13 - "test:watch": "vitest" 13 + "test:watch": "vitest", 14 + "lint": "eslint src/", 15 + "lint:fix": "eslint src/ --fix", 16 + "format": "prettier --write 'src/**/*.{ts,tsx}'", 17 + "format:check": "prettier --check 'src/**/*.{ts,tsx}'" 14 18 }, 15 19 "dependencies": { 16 20 "@phosphor-icons/react": "^2.1.10", 17 21 "@tanstack/react-router": "^1.163.3", 18 22 "comlink": "^4.4.2", 19 23 "dexie": "^4.3.0", 24 + "immer": "^11.1.4", 20 25 "react": "^19.2.4", 21 26 "react-dom": "^19.2.4", 22 27 "zustand": "^5.0.11" ··· 30 35 "@types/react-dom": "^19.2.3", 31 36 "@vitejs/plugin-react": "^5.1.4", 32 37 "daisyui": "^5.5.19", 38 + "eslint": "^10.0.3", 39 + "eslint-config-prettier": "^10.1.8", 40 + "eslint-plugin-functional": "^9.0.4", 41 + "eslint-plugin-jsx-a11y": "^6.10.2", 42 + "eslint-plugin-no-secrets": "^2.3.3", 43 + "eslint-plugin-react-hooks": "^7.0.1", 44 + "eslint-plugin-security": "^4.0.0", 45 + "eslint-plugin-sonarjs": "^4.0.1", 33 46 "fake-indexeddb": "^6.2.5", 34 47 "happy-dom": "^20.8.3", 48 + "jiti": "^2.6.1", 49 + "prettier": "^3.8.1", 50 + "prettier-plugin-tailwindcss": "^0.7.2", 35 51 "tailwindcss": "^4.2.1", 36 52 "typescript": "^5.9.3", 53 + "typescript-eslint": "^8.56.1", 37 54 "vite": "^7.3.1", 38 55 "vite-plugin-comlink": "^5.3.0", 39 56 "vite-plugin-wasm": "^3.5.0",
+9 -14
web/src/components/OpakeLogo.tsx
··· 2 2 sm: { square: 16, wrap: 22, text: "text-[0.9rem]" }, 3 3 md: { square: 22, wrap: 28, text: "text-[1.1rem]" }, 4 4 lg: { square: 30, wrap: 36, text: "text-[1.5rem]" }, 5 - } as const; 5 + } as const 6 6 7 - type LogoSize = keyof typeof SIZES; 7 + type LogoSize = keyof typeof SIZES 8 8 9 - export function OpakeLogo({ size = "md" }: { size?: LogoSize }) { 10 - const { square, wrap, text } = SIZES[size]; 9 + export function OpakeLogo({ size = "md" }: Readonly<{ size?: LogoSize }>) { 10 + const { square, wrap, text } = SIZES[size] 11 11 12 12 return ( 13 13 <div className="flex items-center gap-2.5"> 14 - <div 15 - className="relative shrink-0" 16 - style={{ width: wrap, height: wrap }} 17 - > 14 + <div className="relative shrink-0" style={{ width: wrap, height: wrap }}> 18 15 <div 19 - className="absolute top-0 left-0 rounded-[3px] bg-primary/70" 16 + className="bg-primary/70 absolute top-0 left-0 rounded-[3px]" 20 17 style={{ width: square, height: square }} 21 18 /> 22 19 <div 23 - className="absolute right-0 bottom-0 rounded-[3px] border-[1.5px] border-primary/45 bg-primary/20" 20 + className="border-primary/45 bg-primary/20 absolute right-0 bottom-0 rounded-[3px] border-[1.5px]" 24 21 style={{ width: square, height: square }} 25 22 /> 26 23 </div> 27 - <span 28 - className={`font-display font-medium tracking-[0.05em] text-base-content ${text}`} 29 - > 24 + <span className={`font-display text-base-content font-medium tracking-[0.05em] ${text}`}> 30 25 Opake 31 26 </span> 32 27 </div> 33 - ); 28 + ) 34 29 }
+19 -21
web/src/components/cabinet/FileGridCard.tsx
··· 1 - import { Lock } from "@phosphor-icons/react"; 2 - import { StatusBadge } from "./StatusBadge"; 3 - import { fileIconElement, fileIconColors } from "./file-icons"; 4 - import type { FileItem } from "./types"; 1 + import { LockIcon } from "@phosphor-icons/react" 2 + import { StatusBadge } from "./StatusBadge" 3 + import { fileIconElement, fileIconColors } from "./file-icons" 4 + import type { FileItem } from "./types" 5 5 6 6 interface FileGridCardProps { 7 - item: FileItem; 8 - onClick: () => void; 7 + item: FileItem 8 + onClick: () => void 9 9 } 10 10 11 - export function FileGridCard({ item, onClick }: FileGridCardProps) { 12 - const { bg, text } = fileIconColors(item); 13 - const isFolder = item.kind === "folder"; 11 + export function FileGridCard({ item, onClick }: Readonly<FileGridCardProps>) { 12 + const { bg, text } = fileIconColors(item) 13 + const isFolder = item.kind === "folder" 14 14 15 15 return ( 16 16 <div ··· 19 19 isFolder 20 20 ? (e) => { 21 21 if (e.key === "Enter" || e.key === " ") { 22 - e.preventDefault(); 23 - onClick(); 22 + e.preventDefault() 23 + onClick() 24 24 } 25 25 } 26 26 : undefined ··· 28 28 role={isFolder ? "button" : "article"} 29 29 tabIndex={isFolder ? 0 : undefined} 30 30 aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 31 - className={`card border border-base-300/50 bg-base-100 p-4 shadow-panel-sm transition-all hover:border-base-300 hover:shadow-panel-md ${ 31 + className={`card border-base-300/50 bg-base-100 shadow-panel-sm hover:border-base-300 hover:shadow-panel-md border p-4 transition-all ${ 32 32 isFolder ? "cursor-pointer" : "" 33 33 }`} 34 34 > 35 35 <div className="mb-3 flex items-start justify-between"> 36 36 <div 37 - className={`flex size-[38px] items-center justify-center rounded-[10px] ${bg} ${text}`} 37 + className={`flex size-9.5 items-center justify-center rounded-[10px] ${bg} ${text}`} 38 38 > 39 39 {fileIconElement(item, 17)} 40 40 </div> 41 - <Lock size={11} className="text-text-faint" /> 41 + <LockIcon size={11} className="text-text-faint" /> 42 42 </div> 43 43 44 44 {/* Encrypted preview area */} 45 - <div className="relative mb-3 flex h-[52px] items-center justify-center overflow-hidden rounded-lg border border-primary/10 bg-encrypted-pattern"> 46 - <div className="absolute inset-0 bg-base-100/55 backdrop-blur-[3px]" /> 47 - <Lock size={13} className="relative z-10 text-text-faint" /> 45 + <div className="border-primary/10 bg-encrypted-pattern relative mb-3 flex h-13 items-center justify-center overflow-hidden rounded-lg border"> 46 + <div className="bg-base-100/55 absolute inset-0 backdrop-blur-[3px]" /> 47 + <LockIcon size={13} className="text-text-faint relative z-10" /> 48 48 </div> 49 49 50 - <div className="mb-1.5 truncate text-xs text-base-content"> 51 - {item.name} 52 - </div> 50 + <div className="text-base-content mb-1.5 truncate text-xs">{item.name}</div> 53 51 <div className="flex items-center justify-between"> 54 52 <span className="text-caption text-text-faint">{item.modified}</span> 55 53 <StatusBadge status={item.status} /> 56 54 </div> 57 55 </div> 58 - ); 56 + ) 59 57 }
+23 -31
web/src/components/cabinet/FileListRow.tsx
··· 1 - import { Star, CaretRight } from "@phosphor-icons/react"; 2 - import { StatusBadge } from "./StatusBadge"; 3 - import { fileIconElement, fileIconColors } from "./file-icons"; 4 - import type { FileItem } from "./types"; 1 + import { StarIcon, CaretRightIcon } from "@phosphor-icons/react" 2 + import { StatusBadge } from "./StatusBadge" 3 + import { fileIconElement, fileIconColors } from "./file-icons" 4 + import type { FileItem } from "./types" 5 5 6 6 interface FileListRowProps { 7 - item: FileItem; 8 - onClick: () => void; 9 - onStar: () => void; 7 + item: FileItem 8 + onClick: () => void 9 + onStar: () => void 10 10 } 11 11 12 - export function FileListRow({ item, onClick, onStar }: FileListRowProps) { 13 - const { bg, text } = fileIconColors(item); 14 - const isFolder = item.kind === "folder"; 12 + // eslint-disable-next-line sonarjs/cognitive-complexity -- single-component render with conditional attributes; splitting would fragment the layout 13 + export function FileListRow({ item, onClick, onStar }: Readonly<FileListRowProps>) { 14 + const { bg, text } = fileIconColors(item) 15 + const isFolder = item.kind === "folder" 15 16 16 17 return ( 17 18 <div ··· 20 21 isFolder 21 22 ? (e) => { 22 23 if (e.key === "Enter" || e.key === " ") { 23 - e.preventDefault(); 24 - onClick(); 24 + e.preventDefault() 25 + onClick() 25 26 } 26 27 } 27 28 : undefined ··· 29 30 role={isFolder ? "button" : "row"} 30 31 tabIndex={isFolder ? 0 : undefined} 31 32 aria-label={`${item.name}${isFolder ? ", folder" : `, ${item.fileType ?? "file"}`}`} 32 - className={`flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors hover:bg-bg-hover ${ 33 + className={`hover:bg-bg-hover flex items-center gap-3 rounded-xl px-3 py-2.25 transition-colors ${ 33 34 isFolder ? "cursor-pointer" : "" 34 35 }`} 35 36 > 36 37 {/* Icon */} 37 - <div 38 - className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${bg} ${text}`} 39 - > 38 + <div className={`flex size-8 shrink-0 items-center justify-center rounded-lg ${bg} ${text}`}> 40 39 {fileIconElement(item, 15)} 41 40 </div> 42 41 43 42 {/* Name + meta */} 44 43 <div className="min-w-0 flex-1"> 45 - <div className="truncate text-ui text-base-content"> 46 - {item.name} 47 - </div> 48 - <div className="mt-0.5 flex items-center gap-1.5 text-caption text-text-faint"> 44 + <div className="text-ui text-base-content truncate">{item.name}</div> 45 + <div className="text-caption text-text-faint mt-0.5 flex items-center gap-1.5"> 49 46 <span>{item.modified}</span> 50 47 {item.size && ( 51 48 <> ··· 67 64 <StatusBadge status={item.status} /> 68 65 <button 69 66 onClick={(e) => { 70 - e.stopPropagation(); 71 - onStar(); 67 + e.stopPropagation() 68 + onStar() 72 69 }} 73 - aria-label={item.starred ? "Unstar" : "Star"} 70 + aria-label={item.starred ? "Unstar" : "StarIcon"} 74 71 className={`btn btn-ghost btn-xs p-0.5 ${ 75 72 item.starred ? "text-warning" : "text-text-faint" 76 73 }`} 77 74 > 78 - <Star 79 - size={13} 80 - weight={item.starred ? "fill" : "regular"} 81 - /> 75 + <StarIcon size={13} weight={item.starred ? "fill" : "regular"} /> 82 76 </button> 83 - {isFolder && ( 84 - <CaretRight size={13} className="text-text-faint" /> 85 - )} 77 + {isFolder && <CaretRightIcon size={13} className="text-text-faint" />} 86 78 </div> 87 79 </div> 88 - ); 80 + ) 89 81 }
+97 -96
web/src/components/cabinet/PanelContent.tsx
··· 1 1 import { 2 - Sparkle, 3 - Lock, 4 - ShareNetwork, 5 - Graph, 6 - Question, 7 - ArrowSquareOut, 8 - User, 9 - ShieldCheck, 10 - Bell, 11 - CaretRight, 12 - Trash, 13 - Folder, 14 - } from "@phosphor-icons/react"; 15 - import { FileListRow } from "./FileListRow"; 16 - import { FileGridCard } from "./FileGridCard"; 17 - import type { FileItem, Panel } from "./types"; 18 - import { 19 - ROOT_ITEMS, 20 - SHARED_ITEMS, 21 - DOCUMENTS_ITEMS, 22 - } from "./mock-data"; 2 + SparkleIcon, 3 + LockIcon, 4 + ShareNetworkIcon, 5 + GraphIcon, 6 + QuestionIcon, 7 + ArrowSquareOutIcon, 8 + UserIcon, 9 + ShieldCheckIcon, 10 + BellIcon, 11 + CaretRightIcon, 12 + TrashIcon, 13 + FolderIcon, 14 + } from "@phosphor-icons/react" 15 + import { FileListRow } from "./FileListRow" 16 + import { FileGridCard } from "./FileGridCard" 17 + import type { FileItem, Panel } from "./types" 18 + import { ROOT_ITEMS, SHARED_ITEMS, DOCUMENTS_ITEMS } from "./mock-data" 23 19 24 20 const DOCS_SECTIONS = [ 25 - { id: "getting-started", title: "Getting Started", icon: Sparkle, desc: "Set up your cabinet, create your first encrypted file, and explore the interface." }, 26 - { id: "encryption", title: "Encryption & Keys", icon: Lock, desc: "How end-to-end encryption works in Opake and how your keys are managed." }, 27 - { id: "sharing", title: "Sharing & DIDs", icon: ShareNetwork, desc: "Share files using decentralised identifiers without a central authority." }, 28 - { id: "at-protocol", title: "AT Protocol", icon: Graph, desc: "The open standard powering Opake — identity, data portability, and federation." }, 29 - { id: "faq", title: "FAQ", icon: Question, desc: "Common questions about privacy, security, and how Opake compares to alternatives." }, 30 - ]; 21 + { 22 + id: "getting-started", 23 + title: "Getting Started", 24 + icon: SparkleIcon, 25 + desc: "Set up your cabinet, create your first encrypted file, and explore the interface.", 26 + }, 27 + { 28 + id: "encryption", 29 + title: "Encryption & Keys", 30 + icon: LockIcon, 31 + desc: "How end-to-end encryption works in Opake and how your keys are managed.", 32 + }, 33 + { 34 + id: "sharing", 35 + title: "Sharing & DIDs", 36 + icon: ShareNetworkIcon, 37 + desc: "Share files using decentralised identifiers without a central authority.", 38 + }, 39 + { 40 + id: "at-protocol", 41 + title: "AT Protocol", 42 + icon: GraphIcon, 43 + desc: "The open standard powering Opake — identity, data portability, and federation.", 44 + }, 45 + { 46 + id: "faq", 47 + title: "FAQ", 48 + icon: QuestionIcon, 49 + desc: "Common questions about privacy, security, and how Opake compares to alternatives.", 50 + }, 51 + ] 31 52 32 53 const SETTINGS_SECTIONS = [ 33 - { label: "Account & Identity", desc: "DID: did:plc:7f2ab3c4d…8e91f0", icon: User }, 34 - { label: "Encryption Keys", desc: "Last rotated 14 days ago · Active", icon: Lock }, 35 - { label: "Sharing & Permissions", desc: "3 active collaborators", icon: ShareNetwork }, 36 - { label: "Connected Devices", desc: "2 devices linked", icon: ShieldCheck }, 37 - { label: "Notifications", desc: "Email & in-app alerts", icon: Bell }, 38 - ]; 54 + { label: "Account & Identity", desc: "DID: did:plc:7f2ab3c4d…8e91f0", icon: UserIcon }, 55 + { label: "Encryption Keys", desc: "Last rotated 14 days ago · Active", icon: LockIcon }, 56 + { label: "Sharing & Permissions", desc: "3 active collaborators", icon: ShareNetworkIcon }, 57 + { label: "Connected Devices", desc: "2 devices linked", icon: ShieldCheckIcon }, 58 + { label: "Notifications", desc: "Email & in-app alerts", icon: BellIcon }, 59 + ] 39 60 40 - const ALL_ITEMS = [...ROOT_ITEMS, ...SHARED_ITEMS, ...DOCUMENTS_ITEMS]; 61 + const ALL_ITEMS = [...ROOT_ITEMS, ...SHARED_ITEMS, ...DOCUMENTS_ITEMS] 41 62 42 - function getItemsForPanel( 43 - panel: Panel, 44 - starredIds: ReadonlySet<string>, 45 - ): FileItem[] { 63 + function getItemsForPanel(panel: Panel, starredIds: ReadonlySet<string>): FileItem[] { 46 64 const baseItems = (() => { 47 65 switch (panel.type) { 48 66 case "root": 49 - return ROOT_ITEMS; 67 + return ROOT_ITEMS 50 68 case "shared": 51 - return SHARED_ITEMS; 69 + return SHARED_ITEMS 52 70 case "starred": 53 - return ALL_ITEMS.filter((i) => starredIds.has(i.id)); 71 + return ALL_ITEMS.filter((i) => starredIds.has(i.id)) 54 72 case "encrypted": 55 - return ROOT_ITEMS.filter((i) => i.status === "private"); 73 + return ROOT_ITEMS.filter((i) => i.status === "private") 56 74 case "folder": 57 - return panel.folderId === "f-documents" 58 - ? DOCUMENTS_ITEMS 59 - : ROOT_ITEMS.slice(5); 75 + return panel.folderId === "f-documents" ? DOCUMENTS_ITEMS : ROOT_ITEMS.slice(5) 60 76 default: 61 - return []; 77 + return [] 62 78 } 63 - })(); 79 + })() 64 80 65 81 return baseItems.map((item) => ({ 66 82 ...item, 67 83 starred: starredIds.has(item.id), 68 - })); 84 + })) 69 85 } 70 86 71 87 interface PanelContentProps { 72 - panel: Panel; 73 - viewMode: "list" | "grid"; 74 - starredIds: ReadonlySet<string>; 75 - onOpen: (item: FileItem) => void; 76 - onStar: (id: string) => void; 88 + panel: Panel 89 + viewMode: "list" | "grid" 90 + starredIds: ReadonlySet<string> 91 + onOpen: (item: FileItem) => void 92 + onStar: (id: string) => void 77 93 } 78 94 79 95 export function PanelContent({ ··· 82 98 starredIds, 83 99 onOpen, 84 100 onStar, 85 - }: PanelContentProps) { 101 + }: Readonly<PanelContentProps>) { 86 102 // Docs 87 103 if (panel.type === "docs") { 88 104 return ( 89 105 <div className="p-5"> 90 106 <div className="mb-5"> 91 - <div className="mb-1 text-ui font-medium text-base-content"> 92 - Documentation 93 - </div> 94 - <div className="text-xs text-text-muted"> 107 + <div className="text-ui text-base-content mb-1 font-medium">Documentation</div> 108 + <div className="text-text-muted text-xs"> 95 109 Everything you need to get the most out of Opake. 96 110 </div> 97 111 </div> ··· 99 113 {DOCS_SECTIONS.map((s) => ( 100 114 <div 101 115 key={s.id} 102 - className="card card-bordered cursor-pointer border-base-300/50 bg-base-100 p-3.5" 116 + className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 103 117 > 104 - <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent"> 118 + <div className="bg-accent flex size-8 shrink-0 items-center justify-center rounded-lg"> 105 119 <s.icon size={14} className="text-primary" /> 106 120 </div> 107 121 <div className="flex-1"> 108 - <div className="mb-0.5 text-ui font-medium text-base-content"> 109 - {s.title} 110 - </div> 111 - <div className="text-caption leading-relaxed text-text-muted"> 112 - {s.desc} 113 - </div> 122 + <div className="text-ui text-base-content mb-0.5 font-medium">{s.title}</div> 123 + <div className="text-caption text-text-muted leading-relaxed">{s.desc}</div> 114 124 </div> 115 - <ArrowSquareOut 116 - size={12} 117 - className="mt-0.5 shrink-0 text-text-faint" 118 - /> 125 + <ArrowSquareOutIcon size={12} className="text-text-faint mt-0.5 shrink-0" /> 119 126 </div> 120 127 ))} 121 128 </div> 122 129 </div> 123 - ); 130 + ) 124 131 } 125 132 126 133 // Settings ··· 128 135 return ( 129 136 <div className="p-5"> 130 137 <div className="mb-5"> 131 - <div className="mb-1 text-ui font-medium text-base-content"> 132 - Settings 133 - </div> 134 - <div className="text-xs text-text-muted"> 135 - Manage your account, keys, and preferences. 136 - </div> 138 + <div className="text-ui text-base-content mb-1 font-medium">Settings</div> 139 + <div className="text-text-muted text-xs">Manage your account, keys, and preferences.</div> 137 140 </div> 138 141 <div className="divider mt-0 mb-4" /> 139 142 <div className="flex flex-col gap-1.5"> 140 143 {SETTINGS_SECTIONS.map(({ label, desc, icon: Icon }) => ( 141 144 <div 142 145 key={label} 143 - className="card card-bordered cursor-pointer border-base-300/50 bg-base-100 p-3.5" 146 + className="card card-bordered border-base-300/50 bg-base-100 cursor-pointer p-3.5" 144 147 > 145 - <div className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-bg-stone"> 148 + <div className="bg-bg-stone flex size-8 shrink-0 items-center justify-center rounded-lg"> 146 149 <Icon size={14} className="text-text-muted" /> 147 150 </div> 148 151 <div className="flex-1"> 149 - <div className="text-ui font-medium text-base-content"> 150 - {label} 151 - </div> 152 + <div className="text-ui text-base-content font-medium">{label}</div> 152 153 <div className="text-caption text-text-muted">{desc}</div> 153 154 </div> 154 - <CaretRight size={13} className="text-text-faint" /> 155 + <CaretRightIcon size={13} className="text-text-faint" /> 155 156 </div> 156 157 ))} 157 158 </div> 158 159 </div> 159 - ); 160 + ) 160 161 } 161 162 162 - // Trash 163 + // TrashIcon 163 164 if (panel.type === "trash") { 164 165 return ( 165 166 <div className="hero py-16"> 166 167 <div className="hero-content flex-col text-center"> 167 - <div className="flex size-13 items-center justify-center rounded-[14px] bg-bg-stone"> 168 - <Trash size={22} className="text-text-faint" /> 168 + <div className="bg-bg-stone flex size-13 items-center justify-center rounded-[14px]"> 169 + <TrashIcon size={22} className="text-text-faint" /> 169 170 </div> 170 - <div className="text-ui text-text-muted">Trash is empty</div> 171 - <div className="max-w-60 text-xs leading-relaxed text-text-faint"> 171 + <div className="text-ui text-text-muted">TrashIcon is empty</div> 172 + <div className="text-text-faint max-w-60 text-xs leading-relaxed"> 172 173 Deleted files appear here for 30 days before permanent removal. 173 174 </div> 174 175 </div> 175 176 </div> 176 - ); 177 + ) 177 178 } 178 179 179 - // File browser (list / grid) 180 - const items = getItemsForPanel(panel, starredIds); 180 + // FileIcon browser (list / grid) 181 + const items = getItemsForPanel(panel, starredIds) 181 182 182 183 if (items.length === 0) { 183 184 return ( 184 185 <div className="hero py-16"> 185 186 <div className="hero-content flex-col text-center"> 186 - <div className="flex size-13 items-center justify-center rounded-[14px] bg-accent"> 187 - <Folder size={22} className="text-text-faint" /> 187 + <div className="bg-accent flex size-13 items-center justify-center rounded-[14px]"> 188 + <FolderIcon size={22} className="text-text-faint" /> 188 189 </div> 189 190 <div className="text-ui text-text-muted">Nothing here yet</div> 190 191 </div> 191 192 </div> 192 - ); 193 + ) 193 194 } 194 195 195 196 return ( ··· 217 218 </div> 218 219 )} 219 220 </div> 220 - ); 221 + ) 221 222 }
+1 -1
web/src/components/cabinet/PanelSkeleton.tsx
··· 5 5 <div key={i} className="skeleton h-12 w-full rounded-xl" /> 6 6 ))} 7 7 </div> 8 - ); 8 + ) 9 9 }
+88 -115
web/src/components/cabinet/PanelStack.tsx
··· 1 1 import { 2 - ListBullets, 3 - SquaresFour, 4 - Plus, 5 - X, 6 - UploadSimple, 7 - Folder, 8 - FileText, 9 - BookOpen, 10 - Clock, 11 - ShieldCheck, 12 - Users, 13 - Lock, 14 - } from "@phosphor-icons/react"; 15 - import { PanelContent } from "./PanelContent"; 16 - import { PanelSkeleton } from "./PanelSkeleton"; 17 - import { fileIconElement, fileIconColors } from "./file-icons"; 18 - import { ROOT_ITEMS, SHARED_ITEMS } from "./mock-data"; 19 - import type { FileItem, Panel } from "./types"; 20 - import { panelKey } from "./types"; 2 + ListBulletsIcon, 3 + SquaresFourIcon, 4 + PlusIcon, 5 + XIcon, 6 + UploadSimpleIcon, 7 + FolderIcon, 8 + FileTextIcon, 9 + BookOpenIcon, 10 + ClockIcon, 11 + ShieldCheckIcon, 12 + UsersIcon, 13 + LockIcon, 14 + } from "@phosphor-icons/react" 15 + import { PanelContent } from "./PanelContent" 16 + import { PanelSkeleton } from "./PanelSkeleton" 17 + import { fileIconElement, fileIconColors } from "./file-icons" 18 + import { ROOT_ITEMS, SHARED_ITEMS } from "./mock-data" 19 + import type { FileItem, Panel } from "./types" 20 + import { panelKey } from "./types" 21 21 22 - const FILE_BROWSER_TYPES = new Set([ 23 - "root", 24 - "folder", 25 - "shared", 26 - "starred", 27 - "encrypted", 28 - ]); 22 + const FILE_BROWSER_TYPES = new Set(["root", "folder", "shared", "starred", "encrypted"]) 29 23 30 24 interface PanelStackProps { 31 - panels: Panel[]; 32 - viewMode: "list" | "grid"; 33 - starredIds: ReadonlySet<string>; 34 - loading: boolean; 35 - onViewModeChange: (mode: "list" | "grid") => void; 36 - onOpenItem: (item: FileItem) => void; 37 - onGoToPanel: (index: number) => void; 38 - onClosePanel: () => void; 39 - onStar: (id: string) => void; 25 + panels: Panel[] 26 + viewMode: "list" | "grid" 27 + starredIds: ReadonlySet<string> 28 + loading: boolean 29 + onViewModeChange: (mode: "list" | "grid") => void 30 + onOpenItem: (item: FileItem) => void 31 + onGoToPanel: (index: number) => void 32 + onClosePanel: () => void 33 + onStar: (id: string) => void 40 34 } 41 35 42 36 export function PanelStack({ ··· 49 43 onGoToPanel, 50 44 onClosePanel, 51 45 onStar, 52 - }: PanelStackProps) { 53 - const currentPanel = panels[panels.length - 1]; 54 - const depth = panels.length; 55 - const isFileBrowser = FILE_BROWSER_TYPES.has(currentPanel.type); 46 + }: Readonly<PanelStackProps>) { 47 + const currentPanel = panels[panels.length - 1] 48 + const depth = panels.length 49 + const isFileBrowser = FILE_BROWSER_TYPES.has(currentPanel.type) 56 50 57 51 const footerText = (() => { 58 52 switch (currentPanel.type) { 59 53 case "root": 60 - return `${ROOT_ITEMS.length} items · All encrypted · AT Protocol`; 54 + return `${ROOT_ITEMS.length} items · All encrypted · AT Protocol` 61 55 case "shared": 62 - return `${SHARED_ITEMS.length} shared items · Encrypted`; 56 + return `${SHARED_ITEMS.length} shared items · Encrypted` 63 57 case "starred": 64 - return `${starredIds.size} starred items`; 58 + return `${starredIds.size} starred items` 65 59 case "encrypted": 66 - return `${ROOT_ITEMS.filter((i) => i.status === "private").length} private items`; 60 + return `${ROOT_ITEMS.filter((i) => i.status === "private").length} private items` 67 61 case "folder": 68 - return `${currentPanel.itemCount ?? "–"} items · Encrypted`; 62 + return `${currentPanel.itemCount ?? "–"} items · Encrypted` 69 63 case "docs": 70 - return "Documentation · Opake"; 64 + return "Documentation · Opake" 71 65 case "settings": 72 - return "Account settings"; 66 + return "Account settings" 73 67 case "trash": 74 - return "Trash · 30 day retention"; 68 + return "TrashIcon · 30 day retention" 75 69 } 76 - })(); 70 + })() 77 71 78 72 return ( 79 - <div className="relative flex-1 overflow-hidden p-[22px] pl-7"> 73 + <div className="relative flex-1 overflow-hidden p-5.5 pl-7"> 80 74 {/* Ghost panels — filing cabinet depth */} 81 75 {depth >= 3 && ( 82 - <div className="absolute inset-y-[22px] right-[22px] left-7 z-[1] -translate-x-2.5 -translate-y-2.5 rounded-2xl border border-primary/15 bg-bg-ghost-1" /> 76 + <div className="border-primary/15 bg-bg-ghost-1 absolute inset-y-5.5 right-5.5 left-7 z-1 -translate-x-2.5 -translate-y-2.5 rounded-2xl border" /> 83 77 )} 84 78 {depth >= 2 && ( 85 - <div className="absolute inset-y-[22px] right-[22px] left-7 z-[2] -translate-x-[5px] -translate-y-[5px] rounded-2xl border border-base-300/50 bg-bg-ghost-2 shadow-panel-sm" /> 79 + <div className="border-base-300/50 bg-bg-ghost-2 shadow-panel-sm absolute inset-y-5.5 right-5.5 left-7 z-2 -translate-x-1.25 -translate-y-1.25 rounded-2xl border" /> 86 80 )} 87 81 88 82 {/* Active panel */} 89 - <div className="absolute inset-y-[22px] right-[22px] left-7 z-10 flex flex-col overflow-hidden rounded-2xl border border-base-300/50 bg-base-100 shadow-panel-lg"> 83 + <div className="border-base-300/50 bg-base-100 shadow-panel-lg absolute inset-y-5.5 right-5.5 left-7 z-10 flex flex-col overflow-hidden rounded-2xl border"> 90 84 {/* Panel header */} 91 - <div className="flex shrink-0 items-center gap-2.5 border-b border-base-300/50 bg-base-100/70 px-4 py-[11px]"> 85 + <div className="border-base-300/50 bg-base-100/70 flex shrink-0 items-center gap-2.5 border-b px-4 py-2.75"> 92 86 {/* Breadcrumb */} 93 - <div className="breadcrumbs min-w-0 flex-1 overflow-hidden text-ui"> 87 + <div className="breadcrumbs text-ui min-w-0 flex-1 overflow-hidden"> 94 88 <ul> 95 89 {panels.map((panel, i) => ( 96 90 <li key={panelKey(panel)}> 97 91 <button 98 92 onClick={() => onGoToPanel(i)} 99 93 className={ 100 - i === panels.length - 1 101 - ? "font-medium text-base-content" 102 - : "text-text-faint" 94 + i === panels.length - 1 ? "text-base-content font-medium" : "text-text-faint" 103 95 } 104 96 > 105 97 {panel.title} ··· 113 105 <div className="flex shrink-0 items-center gap-2"> 114 106 {/* View toggle */} 115 107 {isFileBrowser && ( 116 - <div className="join rounded-lg bg-primary/10 p-0.5"> 108 + <div className="join bg-primary/10 rounded-lg p-0.5"> 117 109 <button 118 110 onClick={() => onViewModeChange("list")} 119 111 className={`join-item btn btn-xs rounded-md border-0 ${ 120 112 viewMode === "list" 121 113 ? "bg-base-100 text-secondary shadow-panel-sm" 122 - : "bg-transparent text-text-faint" 114 + : "text-text-faint bg-transparent" 123 115 }`} 124 116 > 125 - <ListBullets size={13} /> 117 + <ListBulletsIcon size={13} /> 126 118 </button> 127 119 <button 128 120 onClick={() => onViewModeChange("grid")} 129 121 className={`join-item btn btn-xs rounded-md border-0 ${ 130 122 viewMode === "grid" 131 123 ? "bg-base-100 text-secondary shadow-panel-sm" 132 - : "bg-transparent text-text-faint" 124 + : "text-text-faint bg-transparent" 133 125 }`} 134 126 > 135 - <SquaresFour size={13} /> 127 + <SquaresFourIcon size={13} /> 136 128 </button> 137 129 </div> 138 130 )} ··· 140 132 {/* New button */} 141 133 <details className="dropdown dropdown-end"> 142 134 <summary className="btn btn-neutral btn-sm gap-1.5 rounded-lg text-xs"> 143 - <Plus size={13} /> 135 + <PlusIcon size={13} /> 144 136 New 145 137 </summary> 146 - <ul className="menu dropdown-content z-50 w-[168px] rounded-xl border border-base-300/50 bg-base-100 p-1 shadow-panel-lg"> 138 + <ul className="menu dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-42 rounded-xl border p-1"> 147 139 {[ 148 - { icon: UploadSimple, label: "Upload file" }, 149 - { icon: Folder, label: "New folder" }, 150 - { icon: FileText, label: "New document" }, 151 - { icon: BookOpen, label: "New note" }, 140 + { icon: UploadSimpleIcon, label: "Upload file" }, 141 + { icon: FolderIcon, label: "New folder" }, 142 + { icon: FileTextIcon, label: "New document" }, 143 + { icon: BookOpenIcon, label: "New note" }, 152 144 ].map(({ icon: Icon, label }) => ( 153 145 <li key={label}> 154 146 <button 155 147 onClick={(e) => { 156 - ( 157 - e.currentTarget.closest( 158 - "details", 159 - ) as HTMLDetailsElement 160 - )?.removeAttribute("open"); 148 + e.currentTarget.closest("details")?.removeAttribute("open") 161 149 }} 162 - className="gap-2.5 text-xs text-secondary" 150 + className="text-secondary gap-2.5 text-xs" 163 151 > 164 152 <Icon size={13} className="text-text-muted" /> 165 153 {label} ··· 171 159 172 160 {/* Close panel */} 173 161 {depth > 1 && ( 174 - <button 175 - onClick={onClosePanel} 176 - className="btn btn-ghost btn-sm btn-square rounded-md" 177 - > 178 - <X size={14} className="text-text-muted" /> 162 + <button onClick={onClosePanel} className="btn btn-ghost btn-sm btn-square rounded-md"> 163 + <XIcon size={14} className="text-text-muted" /> 179 164 </button> 180 165 )} 181 166 </div> ··· 186 171 {/* Recent bar — root list only */} 187 172 {currentPanel.type === "root" && viewMode === "list" && ( 188 173 <div className="px-4 pt-4"> 189 - <div className="mb-3 flex items-center gap-[7px]"> 190 - <Clock size={12} className="text-text-faint" /> 191 - <span className="text-label uppercase tracking-[0.1em] text-text-faint"> 174 + <div className="mb-3 flex items-center gap-1.75"> 175 + <ClockIcon size={12} className="text-text-faint" /> 176 + <span className="text-label text-text-faint tracking-widest uppercase"> 192 177 Recent 193 178 </span> 194 179 </div> 195 180 <div className="flex gap-2 overflow-x-auto pb-3 [scrollbar-width:none]"> 196 181 {ROOT_ITEMS.slice(5, 9).map((item) => { 197 - const { bg, text } = fileIconColors(item); 182 + const { bg, text } = fileIconColors(item) 198 183 return ( 199 184 <div 200 185 key={`r-${item.id}`} 201 - className="card card-bordered w-[130px] shrink-0 cursor-pointer border-base-300/50 bg-base-100 p-3" 186 + className="card card-bordered border-base-300/50 bg-base-100 w-32.5 shrink-0 cursor-pointer p-3" 202 187 > 203 188 <div 204 - className={`mb-2 flex size-[26px] items-center justify-center rounded-md ${bg} ${text}`} 189 + className={`mb-2 flex size-6.5 items-center justify-center rounded-md ${bg} ${text}`} 205 190 > 206 191 {fileIconElement(item, 13)} 207 192 </div> 208 - <div className="truncate text-caption text-base-content"> 209 - {item.name} 210 - </div> 211 - <div className="mt-0.5 text-label text-text-faint"> 212 - {item.modified} 213 - </div> 193 + <div className="text-caption text-base-content truncate">{item.name}</div> 194 + <div className="text-label text-text-faint mt-0.5">{item.modified}</div> 214 195 </div> 215 - ); 196 + ) 216 197 })} 217 198 </div> 218 199 {/* Ornamental divider */} 219 - <div className="divider mb-1 text-micro uppercase tracking-[0.12em] text-text-faint"> 200 + <div className="divider text-micro text-text-faint mb-1 tracking-[0.12em] uppercase"> 220 201 All files 221 202 </div> 222 203 </div> ··· 226 207 {currentPanel.type === "shared" && ( 227 208 <div 228 209 role="alert" 229 - className="alert mx-4 mt-4 gap-2.5 rounded-xl border-success/30 bg-bg-sage p-3" 210 + className="alert border-success/30 bg-bg-sage mx-4 mt-4 gap-2.5 rounded-xl p-3" 230 211 > 231 - <Users 232 - size={13} 233 - className="mt-0.5 shrink-0 text-success" 234 - /> 212 + <UsersIcon size={13} className="text-success mt-0.5 shrink-0" /> 235 213 <div> 236 - <div className="mb-0.5 text-xs font-medium text-success"> 214 + <div className="text-success mb-0.5 text-xs font-medium"> 237 215 Shared via decentralised identity 238 216 </div> 239 - <div className="text-caption leading-relaxed text-success/80"> 240 - Files shared via DID. Encrypted in transit and at rest — only 241 - invited parties can decrypt. 217 + <div className="text-caption text-success/80 leading-relaxed"> 218 + Files shared via DID. Encrypted in transit and at rest — only invited parties can 219 + decrypt. 242 220 </div> 243 221 </div> 244 222 </div> ··· 246 224 {currentPanel.type === "encrypted" && ( 247 225 <div 248 226 role="alert" 249 - className="alert mx-4 mt-4 gap-2.5 rounded-xl border-border-accent bg-accent p-3" 227 + className="alert border-border-accent bg-accent mx-4 mt-4 gap-2.5 rounded-xl p-3" 250 228 > 251 - <Lock 252 - size={13} 253 - className="mt-0.5 shrink-0 text-primary" 254 - /> 229 + <LockIcon size={13} className="text-primary mt-0.5 shrink-0" /> 255 230 <div> 256 - <div className="mb-0.5 text-xs font-medium text-accent-content"> 231 + <div className="text-accent-content mb-0.5 text-xs font-medium"> 257 232 Private encrypted files 258 233 </div> 259 - <div className="text-caption leading-relaxed text-primary"> 234 + <div className="text-caption text-primary leading-relaxed"> 260 235 Only you can decrypt these files. Not shared with anyone. 261 236 </div> 262 237 </div> ··· 277 252 </div> 278 253 279 254 {/* Panel footer */} 280 - <div className="flex shrink-0 items-center gap-2 border-t border-base-300/50 bg-base-100/60 px-4 py-[9px]"> 281 - <ShieldCheck size={11} className="text-primary" /> 255 + <div className="border-base-300/50 bg-base-100/60 flex shrink-0 items-center gap-2 border-t px-4 py-2.25"> 256 + <ShieldCheckIcon size={11} className="text-primary" /> 282 257 <span className="text-caption text-text-faint">{footerText}</span> 283 258 <div className="flex-1" /> 284 259 {depth > 1 && ( 285 - <span className="font-display text-ui italic text-text-faint"> 286 - {depth} panels open 287 - </span> 260 + <span className="font-display text-ui text-text-faint italic">{depth} panels open</span> 288 261 )} 289 262 </div> 290 263 </div> 291 264 </div> 292 - ); 265 + ) 293 266 }
+35 -43
web/src/components/cabinet/Sidebar.tsx
··· 1 1 import { 2 - Folder, 3 - Lock, 4 - Users, 5 - Star, 6 - BookOpen, 7 - Trash, 8 - Gear, 9 - } from "@phosphor-icons/react"; 10 - import { Link } from "@tanstack/react-router"; 11 - import { OpakeLogo } from "../OpakeLogo"; 12 - import { SidebarItem } from "./SidebarItem"; 13 - import type { PanelType, SectionType } from "./types"; 2 + FolderIcon, 3 + LockIcon, 4 + UsersIcon, 5 + StarIcon, 6 + BookOpenIcon, 7 + TrashIcon, 8 + GearIcon, 9 + } from "@phosphor-icons/react" 10 + import { Link } from "@tanstack/react-router" 11 + import { OpakeLogo } from "../OpakeLogo" 12 + import { SidebarItem } from "./SidebarItem" 13 + import type { PanelType, SectionType } from "./types" 14 14 15 15 const MAIN_NAV = [ 16 - { type: "root" as const, icon: Folder, label: "The Cabinet" }, 17 - { type: "encrypted" as const, icon: Lock, label: "Encrypted" }, 18 - { type: "shared" as const, icon: Users, label: "Shared with me", badge: "4" }, 19 - { type: "starred" as const, icon: Star, label: "Starred" }, 20 - ]; 16 + { type: "root" as const, icon: FolderIcon, label: "The Cabinet" }, 17 + { type: "encrypted" as const, icon: LockIcon, label: "Encrypted" }, 18 + { type: "shared" as const, icon: UsersIcon, label: "Shared with me", badge: "4" }, 19 + { type: "starred" as const, icon: StarIcon, label: "Starred" }, 20 + ] 21 21 22 22 const BOTTOM_NAV = [ 23 - { type: "docs" as const, icon: BookOpen, label: "Docs & Help" }, 24 - { type: "trash" as const, icon: Trash, label: "Trash" }, 25 - { type: "settings" as const, icon: Gear, label: "Settings" }, 26 - ]; 23 + { type: "docs" as const, icon: BookOpenIcon, label: "Docs & Help" }, 24 + { type: "trash" as const, icon: TrashIcon, label: "TrashIcon" }, 25 + { type: "settings" as const, icon: GearIcon, label: "Settings" }, 26 + ] 27 27 28 28 const WORKSPACES = [ 29 29 { id: "ws-personal", name: "Personal", count: 3 }, 30 30 { id: "ws-team", name: "Team Alpha", count: 2 }, 31 - ]; 31 + ] 32 32 33 33 interface SidebarProps { 34 - activePanelType: PanelType; 35 - panelDepth: number; 36 - onOpenSection: (type: SectionType, title: string) => void; 34 + activePanelType: PanelType 35 + panelDepth: number 36 + onOpenSection: (type: SectionType, title: string) => void 37 37 } 38 38 39 - export function Sidebar({ 40 - activePanelType, 41 - panelDepth, 42 - onOpenSection, 43 - }: SidebarProps) { 39 + export function Sidebar({ activePanelType, panelDepth, onOpenSection }: Readonly<SidebarProps>) { 44 40 return ( 45 - <aside className="flex w-[212px] shrink-0 flex-col border-r border-base-300/50 bg-base-200 px-3 py-4"> 41 + <aside className="border-base-300/50 bg-base-200 flex w-53 shrink-0 flex-col border-r px-3 py-4"> 46 42 {/* Logo */} 47 43 <div className="mb-5 px-0.5"> 48 44 <Link to="/" className="inline-block"> ··· 52 48 53 49 {/* Storage */} 54 50 <div className="mb-5 px-1"> 55 - <div className="mb-1.5 flex justify-between text-caption text-text-faint"> 51 + <div className="text-caption text-text-faint mb-1.5 flex justify-between"> 56 52 <span>Storage</span> 57 53 <span>3.1 / 10 GB</span> 58 54 </div> 59 - <progress 60 - className="progress progress-primary h-[3px] w-full" 61 - value={31} 62 - max={100} 63 - /> 55 + <progress className="progress progress-primary h-0.75 w-full" value={31} max={100} /> 64 56 </div> 65 57 66 - <div className="divider my-0 mx-1" /> 58 + <div className="divider mx-1 my-0" /> 67 59 68 60 {/* Main nav */} 69 61 <nav className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto"> ··· 79 71 ))} 80 72 81 73 {/* Workspaces */} 82 - <div className="mt-3.5 mb-1.5 ml-1 text-label uppercase tracking-[0.1em] text-text-faint"> 74 + <div className="text-label text-text-faint mt-3.5 mb-1.5 ml-1 tracking-widest uppercase"> 83 75 Workspaces 84 76 </div> 85 77 {WORKSPACES.map((ws) => ( 86 78 <button 87 79 key={ws.id} 88 - className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-ui text-text-muted hover:bg-bg-hover" 80 + className="text-ui text-text-muted hover:bg-bg-hover flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 text-left" 89 81 > 90 - <div className="flex size-5 shrink-0 items-center justify-center rounded-md bg-accent text-micro font-semibold text-primary"> 82 + <div className="bg-accent text-micro text-primary flex size-5 shrink-0 items-center justify-center rounded-md font-semibold"> 91 83 {ws.name[0]} 92 84 </div> 93 85 <span className="flex-1">{ws.name}</span> ··· 98 90 99 91 {/* Bottom nav */} 100 92 <div> 101 - <div className="divider my-0 mx-1" /> 93 + <div className="divider mx-1 my-0" /> 102 94 <div className="flex flex-col gap-0.5"> 103 95 {BOTTOM_NAV.map(({ type, icon, label }) => ( 104 96 <SidebarItem ··· 112 104 </div> 113 105 </div> 114 106 </aside> 115 - ); 107 + ) 116 108 }
+12 -19
web/src/components/cabinet/SidebarItem.tsx
··· 1 - import type { Icon as PhosphorIcon } from "@phosphor-icons/react"; 1 + import type { Icon as PhosphorIcon } from "@phosphor-icons/react" 2 2 3 3 interface SidebarItemProps { 4 - icon: PhosphorIcon; 5 - label: string; 6 - active: boolean; 7 - badge?: string | number; 8 - onClick: () => void; 4 + icon: PhosphorIcon 5 + label: string 6 + active: boolean 7 + badge?: string | number 8 + onClick: () => void 9 9 } 10 10 11 11 export function SidebarItem({ ··· 14 14 active, 15 15 badge, 16 16 onClick, 17 - }: SidebarItemProps) { 17 + }: Readonly<SidebarItemProps>) { 18 18 return ( 19 19 <button 20 20 onClick={onClick} 21 - className={`flex w-full items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-ui transition-colors ${ 22 - active 23 - ? "bg-accent text-primary" 24 - : "text-text-muted hover:bg-bg-hover" 21 + className={`text-ui flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.75 text-left transition-colors ${ 22 + active ? "bg-accent text-primary" : "text-text-muted hover:bg-bg-hover" 25 23 }`} 26 24 > 27 - <Icon 28 - size={14} 29 - weight={active ? "fill" : "regular"} 30 - /> 25 + <Icon size={14} weight={active ? "fill" : "regular"} /> 31 26 <span className="flex-1">{label}</span> 32 27 {badge !== undefined && ( 33 28 <span 34 29 className={`badge badge-xs rounded-md ${ 35 - active 36 - ? "bg-primary/20 text-primary" 37 - : "bg-primary/10 text-text-muted" 30 + active ? "bg-primary/20 text-primary" : "bg-primary/10 text-text-muted" 38 31 }`} 39 32 > 40 33 {badge} 41 34 </span> 42 35 )} 43 36 </button> 44 - ); 37 + ) 45 38 }
+13 -16
web/src/components/cabinet/StatusBadge.tsx
··· 1 - import { Lock, Users, Globe } from "@phosphor-icons/react"; 2 - import type { EncStatus } from "./types"; 1 + import { LockIcon, UsersIcon, GlobeIcon } from "@phosphor-icons/react" 2 + import type { EncStatus } from "./types" 3 3 4 - const VARIANTS: Record< 5 - EncStatus, 6 - { className: string; icon: typeof Lock; label: string } 4 + const VARIANTS: Readonly< 5 + Record<EncStatus, { className: string; icon: typeof LockIcon; label: string }> 7 6 > = { 8 7 private: { 9 8 className: "badge-accent text-primary border-border-accent", 10 - icon: Lock, 9 + icon: LockIcon, 11 10 label: "Private", 12 11 }, 13 12 shared: { 14 13 className: "bg-bg-sage text-success border-success/30", 15 - icon: Users, 14 + icon: UsersIcon, 16 15 label: "Shared", 17 16 }, 18 17 public: { 19 18 className: "bg-bg-stone text-text-muted border-base-300", 20 - icon: Globe, 19 + icon: GlobeIcon, 21 20 label: "Public", 22 21 }, 23 - }; 22 + } 24 23 25 - export function StatusBadge({ status }: { status: EncStatus }) { 26 - const variant = VARIANTS[status]; 27 - const Icon = variant.icon; 24 + export function StatusBadge({ status }: Readonly<{ status: EncStatus }>) { 25 + const variant = VARIANTS[status] 26 + const Icon = variant.icon 28 27 29 28 return ( 30 - <span 31 - className={`badge badge-sm gap-1 border text-label tracking-wide ${variant.className}`} 32 - > 29 + <span className={`badge badge-sm text-label gap-1 border tracking-wide ${variant.className}`}> 33 30 <Icon size={8} weight="bold" /> 34 31 {variant.label} 35 32 </span> 36 - ); 33 + ) 37 34 }
+40 -52
web/src/components/cabinet/TopBar.tsx
··· 1 1 import { 2 - MagnifyingGlass, 3 - X, 4 - ShieldCheck, 5 - Bell, 6 - User, 7 - Lock, 8 - Gear, 9 - SignOut, 10 - } from "@phosphor-icons/react"; 11 - import { Link } from "@tanstack/react-router"; 2 + MagnifyingGlassIcon, 3 + XIcon, 4 + ShieldCheckIcon, 5 + BellIcon, 6 + UserIcon, 7 + LockIcon, 8 + GearIcon, 9 + SignOutIcon, 10 + } from "@phosphor-icons/react" 11 + import { Link } from "@tanstack/react-router" 12 12 13 13 interface TopBarProps { 14 - searchQuery: string; 15 - onSearchChange: (query: string) => void; 16 - onOpenSettings: () => void; 14 + searchQuery: string 15 + onSearchChange: (query: string) => void 16 + onOpenSettings: () => void 17 17 } 18 18 19 19 function closeDropdown(e: React.MouseEvent) { 20 - (e.currentTarget.closest("details") as HTMLDetailsElement)?.removeAttribute( 21 - "open", 22 - ); 20 + e.currentTarget.closest("details")?.removeAttribute("open") 23 21 } 24 22 25 - export function TopBar({ 26 - searchQuery, 27 - onSearchChange, 28 - onOpenSettings, 29 - }: TopBarProps) { 23 + export function TopBar({ searchQuery, onSearchChange, onOpenSettings }: Readonly<TopBarProps>) { 30 24 return ( 31 - <header className="flex shrink-0 items-center gap-3 border-b border-base-300/50 bg-base-300/90 px-5 py-2.5 backdrop-blur-[10px]"> 25 + <header className="border-base-300/50 bg-base-300/90 flex shrink-0 items-center gap-3 border-b px-5 py-2.5 backdrop-blur-[10px]"> 32 26 {/* Search */} 33 - <label className="input input-bordered flex max-w-[360px] flex-1 items-center gap-2 rounded-lg border-base-300/50 bg-base-100/80 py-[7px] text-ui"> 34 - <MagnifyingGlass size={13} className="text-text-faint" /> 27 + <label className="input input-bordered border-base-300/50 bg-base-100/80 text-ui flex max-w-90 flex-1 items-center gap-2 rounded-lg py-1.75"> 28 + <MagnifyingGlassIcon size={13} className="text-text-faint" /> 35 29 <input 36 30 type="text" 37 31 placeholder="Search your cabinet…" 38 32 value={searchQuery} 39 33 onChange={(e) => onSearchChange(e.target.value)} 40 - className="grow bg-transparent text-secondary" 34 + className="text-secondary grow bg-transparent" 41 35 /> 42 36 {searchQuery && ( 43 37 <button 44 38 onClick={() => onSearchChange("")} 45 - className="btn btn-ghost btn-xs p-0 text-text-faint" 39 + className="btn btn-ghost btn-xs text-text-faint p-0" 46 40 > 47 - <X size={12} /> 41 + <XIcon size={12} /> 48 42 </button> 49 43 )} 50 44 </label> ··· 52 46 <div className="flex-1" /> 53 47 54 48 {/* E2E badge */} 55 - <div className="badge badge-outline gap-1.5 border-border-accent bg-accent py-3 text-caption text-primary"> 56 - <ShieldCheck size={12} weight="bold" /> 49 + <div className="badge badge-outline border-border-accent bg-accent text-caption text-primary gap-1.5 py-3"> 50 + <ShieldCheckIcon size={12} weight="bold" /> 57 51 End-to-end encrypted 58 52 </div> 59 53 ··· 61 55 <div className="indicator"> 62 56 <span className="indicator-item badge badge-primary badge-xs size-1.5 p-0" /> 63 57 <button className="btn btn-ghost btn-sm btn-square rounded-lg"> 64 - <Bell size={15} className="text-text-muted" /> 58 + <BellIcon size={15} className="text-text-muted" /> 65 59 </button> 66 60 </div> 67 61 68 - {/* User menu */} 62 + {/* UserIcon menu */} 69 63 <details className="dropdown dropdown-end"> 70 64 <summary className="btn btn-ghost btn-sm gap-2 rounded-lg pl-1"> 71 - <div className="flex size-7 items-center justify-center rounded-full bg-accent text-caption font-semibold text-primary"> 65 + <div className="bg-accent text-caption text-primary flex size-7 items-center justify-center rounded-full font-semibold"> 72 66 A 73 67 </div> 74 - <span className="text-xs font-normal text-secondary"> 75 - alice.bsky.social 76 - </span> 68 + <span className="text-secondary text-xs font-normal">alice.bsky.social</span> 77 69 </summary> 78 - <div className="dropdown-content z-50 w-[210px] rounded-xl border border-base-300/50 bg-base-100 shadow-panel-lg"> 79 - <div className="border-b border-base-300/50 px-3.5 py-2.5"> 80 - <div className="text-ui font-medium text-base-content"> 81 - alice.bsky.social 82 - </div> 83 - <div className="mt-0.5 text-caption text-text-faint"> 84 - did:plc:7f2ab3c4…8e91 85 - </div> 70 + <div className="dropdown-content border-base-300/50 bg-base-100 shadow-panel-lg z-50 w-52.5 rounded-xl border"> 71 + <div className="border-base-300/50 border-b px-3.5 py-2.5"> 72 + <div className="text-ui text-base-content font-medium">alice.bsky.social</div> 73 + <div className="text-caption text-text-faint mt-0.5">did:plc:7f2ab3c4…8e91</div> 86 74 </div> 87 75 <ul className="menu p-1"> 88 76 {[ 89 - { icon: User, label: "Profile & DID" }, 90 - { icon: Lock, label: "Encryption Keys" }, 91 - { icon: Gear, label: "Settings" }, 77 + { icon: UserIcon, label: "Profile & DID" }, 78 + { icon: LockIcon, label: "Encryption Keys" }, 79 + { icon: GearIcon, label: "Settings" }, 92 80 ].map(({ icon: Icon, label }) => ( 93 81 <li key={label}> 94 82 <button 95 83 onClick={(e) => { 96 - onOpenSettings(); 97 - closeDropdown(e); 84 + onOpenSettings() 85 + closeDropdown(e) 98 86 }} 99 - className="gap-2.5 text-xs text-secondary" 87 + className="text-secondary gap-2.5 text-xs" 100 88 > 101 89 <Icon size={13} /> 102 90 {label} ··· 107 95 <div className="divider my-0.5" /> 108 96 <ul className="menu p-1 pt-0"> 109 97 <li> 110 - <Link to="/" className="gap-2.5 text-xs text-error"> 111 - <SignOut size={13} /> 98 + <Link to="/" className="text-error gap-2.5 text-xs"> 99 + <SignOutIcon size={13} /> 112 100 Sign out 113 101 </Link> 114 102 </li> ··· 116 104 </div> 117 105 </details> 118 106 </header> 119 - ); 107 + ) 120 108 }
+21 -21
web/src/components/cabinet/file-icons.tsx
··· 1 1 import { 2 - Folder, 3 - FileText, 4 - File, 5 - BookOpen, 6 - Archive, 7 - } from "@phosphor-icons/react"; 8 - import type { FileItem } from "./types"; 2 + FolderIcon, 3 + FileTextIcon, 4 + FileIcon, 5 + BookOpenIcon, 6 + ArchiveIcon, 7 + } from "@phosphor-icons/react" 8 + import type { FileItem } from "./types" 9 9 10 10 interface IconStyle { 11 - bg: string; 12 - text: string; 11 + bg: string 12 + text: string 13 13 } 14 14 15 - const FOLDER_STYLE: IconStyle = { bg: "bg-accent", text: "text-primary" }; 15 + const FOLDER_STYLE: Readonly<IconStyle> = { bg: "bg-accent", text: "text-primary" } 16 16 17 - const FILE_TYPE_STYLES: Record<string, IconStyle> = { 17 + const FILE_TYPE_STYLES: Readonly<Record<string, IconStyle>> = { 18 18 document: { bg: "bg-file-doc-bg", text: "text-file-doc" }, 19 19 spreadsheet: { bg: "bg-file-sheet-bg", text: "text-file-sheet" }, 20 20 pdf: { bg: "bg-file-pdf-bg", text: "text-file-pdf" }, 21 21 note: { bg: "bg-accent", text: "text-file-note" }, 22 22 code: { bg: "bg-file-code-bg", text: "text-file-code" }, 23 23 archive: { bg: "bg-bg-stone", text: "text-text-muted" }, 24 - }; 24 + } 25 25 26 - const DEFAULT_STYLE: IconStyle = { bg: "bg-bg-stone", text: "text-text-muted" }; 26 + const DEFAULT_STYLE: Readonly<IconStyle> = { bg: "bg-bg-stone", text: "text-text-muted" } 27 27 28 28 export function fileIconColors(item: FileItem): IconStyle { 29 - if (item.kind === "folder") return FOLDER_STYLE; 30 - return FILE_TYPE_STYLES[item.fileType ?? ""] ?? DEFAULT_STYLE; 29 + if (item.kind === "folder") return FOLDER_STYLE 30 + return FILE_TYPE_STYLES[item.fileType ?? ""] ?? DEFAULT_STYLE 31 31 } 32 32 33 33 export function fileIconElement(item: FileItem, size = 15) { 34 - if (item.kind === "folder") return <Folder size={size} weight="fill" />; 34 + if (item.kind === "folder") return <FolderIcon size={size} weight="fill" /> 35 35 switch (item.fileType) { 36 36 case "document": 37 - return <FileText size={size} />; 37 + return <FileTextIcon size={size} /> 38 38 case "spreadsheet": 39 - return <File size={size} />; 39 + return <FileIcon size={size} /> 40 40 case "note": 41 - return <BookOpen size={size} />; 41 + return <BookOpenIcon size={size} /> 42 42 case "archive": 43 - return <Archive size={size} />; 43 + return <ArchiveIcon size={size} /> 44 44 default: 45 - return <File size={size} />; 45 + return <FileIcon size={size} /> 46 46 } 47 47 }
+215 -26
web/src/components/cabinet/mock-data.ts
··· 1 - import type { FileItem } from "./types"; 1 + import type { FileItem } from "./types" 2 2 3 - export const ROOT_ITEMS: FileItem[] = [ 4 - { id: "f-documents", name: "Documents", kind: "folder", encrypted: true, status: "private", items: 23, modified: "2 hours ago", starred: false }, 5 - { id: "f-projects", name: "Projects", kind: "folder", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did"], items: 7, modified: "Yesterday", starred: true }, 6 - { id: "f-photos", name: "Photos", kind: "folder", encrypted: true, status: "private", items: 156, modified: "3 days ago", starred: false }, 7 - { id: "f-notes", name: "Notes", kind: "folder", encrypted: true, status: "private", items: 44, modified: "Just now", starred: false }, 8 - { id: "f-archive", name: "Archive", kind: "folder", encrypted: true, status: "private", items: 12, modified: "1 week ago", starred: false }, 9 - { id: "fi-strategy", name: "Q4 Strategy.doc", kind: "file", fileType: "document", encrypted: true, status: "private", size: "245 KB", modified: "2 hours ago", starred: true }, 10 - { id: "fi-budget", name: "Budget 2026.xlsx", kind: "file", fileType: "spreadsheet", encrypted: true, status: "shared", sharedWith: ["carol.did"], size: "1.2 MB", modified: "3 days ago", starred: false }, 11 - { id: "fi-brief", name: "Design Brief.pdf", kind: "file", fileType: "pdf", encrypted: true, status: "private", size: "3.4 MB", modified: "Yesterday", starred: true }, 12 - { id: "fi-notes", name: "Team Notes.md", kind: "file", fileType: "note", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did", "carol.did"], size: "18 KB", modified: "Just now", starred: false }, 13 - { id: "fi-api", name: "API Contracts.json", kind: "file", fileType: "code", encrypted: true, status: "private", size: "67 KB", modified: "1 week ago", starred: false }, 14 - ]; 3 + export const ROOT_ITEMS: readonly FileItem[] = [ 4 + { 5 + id: "f-documents", 6 + name: "Documents", 7 + kind: "folder", 8 + encrypted: true, 9 + status: "private", 10 + items: 23, 11 + modified: "2 hours ago", 12 + starred: false, 13 + }, 14 + { 15 + id: "f-projects", 16 + name: "Projects", 17 + kind: "folder", 18 + encrypted: true, 19 + status: "shared", 20 + sharedWith: ["alice.did", "bob.did"], 21 + items: 7, 22 + modified: "Yesterday", 23 + starred: true, 24 + }, 25 + { 26 + id: "f-photos", 27 + name: "Photos", 28 + kind: "folder", 29 + encrypted: true, 30 + status: "private", 31 + items: 156, 32 + modified: "3 days ago", 33 + starred: false, 34 + }, 35 + { 36 + id: "f-notes", 37 + name: "Notes", 38 + kind: "folder", 39 + encrypted: true, 40 + status: "private", 41 + items: 44, 42 + modified: "Just now", 43 + starred: false, 44 + }, 45 + { 46 + id: "f-archive", 47 + name: "ArchiveIcon", 48 + kind: "folder", 49 + encrypted: true, 50 + status: "private", 51 + items: 12, 52 + modified: "1 week ago", 53 + starred: false, 54 + }, 55 + { 56 + id: "fi-strategy", 57 + name: "Q4 Strategy.doc", 58 + kind: "file", 59 + fileType: "document", 60 + encrypted: true, 61 + status: "private", 62 + size: "245 KB", 63 + modified: "2 hours ago", 64 + starred: true, 65 + }, 66 + { 67 + id: "fi-budget", 68 + name: "Budget 2026.xlsx", 69 + kind: "file", 70 + fileType: "spreadsheet", 71 + encrypted: true, 72 + status: "shared", 73 + sharedWith: ["carol.did"], 74 + size: "1.2 MB", 75 + modified: "3 days ago", 76 + starred: false, 77 + }, 78 + { 79 + id: "fi-brief", 80 + name: "Design Brief.pdf", 81 + kind: "file", 82 + fileType: "pdf", 83 + encrypted: true, 84 + status: "private", 85 + size: "3.4 MB", 86 + modified: "Yesterday", 87 + starred: true, 88 + }, 89 + { 90 + id: "fi-notes", 91 + name: "Team Notes.md", 92 + kind: "file", 93 + fileType: "note", 94 + encrypted: true, 95 + status: "shared", 96 + sharedWith: ["alice.did", "bob.did", "carol.did"], 97 + size: "18 KB", 98 + modified: "Just now", 99 + starred: false, 100 + }, 101 + { 102 + id: "fi-api", 103 + name: "API Contracts.json", 104 + kind: "file", 105 + fileType: "code", 106 + encrypted: true, 107 + status: "private", 108 + size: "67 KB", 109 + modified: "1 week ago", 110 + starred: false, 111 + }, 112 + ] 15 113 16 - export const DOCUMENTS_ITEMS: FileItem[] = [ 17 - { id: "d-reports", name: "Reports", kind: "folder", encrypted: true, status: "private", items: 8, modified: "1 week ago", starred: false }, 18 - { id: "d-contracts", name: "Contracts", kind: "folder", encrypted: true, status: "shared", sharedWith: ["legal.did"], items: 5, modified: "2 weeks ago", starred: false }, 19 - { id: "d-thesis", name: "Thesis Draft v4.doc", kind: "file", fileType: "document", encrypted: true, status: "private", size: "1.8 MB", modified: "3 days ago", starred: true }, 20 - { id: "d-cv", name: "CV 2026.pdf", kind: "file", fileType: "pdf", encrypted: true, status: "private", size: "340 KB", modified: "1 month ago", starred: false }, 21 - { id: "d-ref", name: "Reference Notes.md", kind: "file", fileType: "note", encrypted: true, status: "private", size: "88 KB", modified: "5 days ago", starred: false }, 22 - ]; 114 + export const DOCUMENTS_ITEMS: readonly FileItem[] = [ 115 + { 116 + id: "d-reports", 117 + name: "Reports", 118 + kind: "folder", 119 + encrypted: true, 120 + status: "private", 121 + items: 8, 122 + modified: "1 week ago", 123 + starred: false, 124 + }, 125 + { 126 + id: "d-contracts", 127 + name: "Contracts", 128 + kind: "folder", 129 + encrypted: true, 130 + status: "shared", 131 + sharedWith: ["legal.did"], 132 + items: 5, 133 + modified: "2 weeks ago", 134 + starred: false, 135 + }, 136 + { 137 + id: "d-thesis", 138 + name: "Thesis Draft v4.doc", 139 + kind: "file", 140 + fileType: "document", 141 + encrypted: true, 142 + status: "private", 143 + size: "1.8 MB", 144 + modified: "3 days ago", 145 + starred: true, 146 + }, 147 + { 148 + id: "d-cv", 149 + name: "CV 2026.pdf", 150 + kind: "file", 151 + fileType: "pdf", 152 + encrypted: true, 153 + status: "private", 154 + size: "340 KB", 155 + modified: "1 month ago", 156 + starred: false, 157 + }, 158 + { 159 + id: "d-ref", 160 + name: "Reference Notes.md", 161 + kind: "file", 162 + fileType: "note", 163 + encrypted: true, 164 + status: "private", 165 + size: "88 KB", 166 + modified: "5 days ago", 167 + starred: false, 168 + }, 169 + ] 23 170 24 - export const SHARED_ITEMS: FileItem[] = [ 25 - { id: "sh-1", name: "Product Roadmap.doc", kind: "file", fileType: "document", encrypted: true, status: "shared", sharedWith: ["team.did"], size: "512 KB", modified: "1 hour ago", starred: false }, 26 - { id: "sh-2", name: "Sprint Board", kind: "folder", encrypted: true, status: "shared", sharedWith: ["alice.did", "bob.did", "carol.did"], items: 9, modified: "30 min ago", starred: true }, 27 - { id: "sh-3", name: "Brand Assets", kind: "folder", encrypted: true, status: "shared", sharedWith: ["design.did"], items: 34, modified: "2 days ago", starred: false }, 28 - { id: "sh-4", name: "Meeting Notes Q1.md", kind: "file", fileType: "note", encrypted: true, status: "shared", sharedWith: ["alice.did"], size: "22 KB", modified: "1 week ago", starred: false }, 29 - ]; 171 + export const SHARED_ITEMS: readonly FileItem[] = [ 172 + { 173 + id: "sh-1", 174 + name: "Product Roadmap.doc", 175 + kind: "file", 176 + fileType: "document", 177 + encrypted: true, 178 + status: "shared", 179 + sharedWith: ["team.did"], 180 + size: "512 KB", 181 + modified: "1 hour ago", 182 + starred: false, 183 + }, 184 + { 185 + id: "sh-2", 186 + name: "Sprint Board", 187 + kind: "folder", 188 + encrypted: true, 189 + status: "shared", 190 + sharedWith: ["alice.did", "bob.did", "carol.did"], 191 + items: 9, 192 + modified: "30 min ago", 193 + starred: true, 194 + }, 195 + { 196 + id: "sh-3", 197 + name: "Brand Assets", 198 + kind: "folder", 199 + encrypted: true, 200 + status: "shared", 201 + sharedWith: ["design.did"], 202 + items: 34, 203 + modified: "2 days ago", 204 + starred: false, 205 + }, 206 + { 207 + id: "sh-4", 208 + name: "Meeting Notes Q1.md", 209 + kind: "file", 210 + fileType: "note", 211 + encrypted: true, 212 + status: "shared", 213 + sharedWith: ["alice.did"], 214 + size: "22 KB", 215 + modified: "1 week ago", 216 + starred: false, 217 + }, 218 + ]
+17 -24
web/src/components/cabinet/types.ts
··· 1 - export type EncStatus = "private" | "shared" | "public"; 1 + export type EncStatus = "private" | "shared" | "public" 2 2 3 - export type FileType = 4 - | "document" 5 - | "spreadsheet" 6 - | "pdf" 7 - | "image" 8 - | "code" 9 - | "note" 10 - | "archive"; 3 + export type FileType = "document" | "spreadsheet" | "pdf" | "image" | "code" | "note" | "archive" 11 4 12 5 export interface FileItem { 13 - id: string; 14 - name: string; 15 - kind: "file" | "folder"; 16 - fileType?: FileType; 17 - encrypted: boolean; 18 - status: EncStatus; 19 - sharedWith?: string[]; 20 - size?: string; 21 - items?: number; 22 - modified: string; 23 - starred: boolean; 6 + id: string 7 + name: string 8 + kind: "file" | "folder" 9 + fileType?: FileType 10 + encrypted: boolean 11 + status: EncStatus 12 + sharedWith?: string[] 13 + size?: string 14 + items?: number 15 + modified: string 16 + starred: boolean 24 17 } 25 18 26 19 export type SectionType = ··· 30 23 | "encrypted" 31 24 | "docs" 32 25 | "trash" 33 - | "settings"; 26 + | "settings" 34 27 35 - export type PanelType = SectionType | "folder"; 28 + export type PanelType = SectionType | "folder" 36 29 37 30 export type Panel = 38 31 | { type: "folder"; folderId: string; title: string; itemCount?: number } 39 - | { type: SectionType; title: string }; 32 + | { type: SectionType; title: string } 40 33 41 34 export function panelKey(panel: Panel): string { 42 - return panel.type === "folder" ? panel.folderId : panel.type; 35 + return panel.type === "folder" ? panel.folderId : panel.type 43 36 }
+103 -91
web/src/lib/api.ts
··· 1 1 // XRPC and AppView API helpers. 2 2 3 - import type { OAuthSession, Session } from "@/lib/storage-types"; 4 - import type { TokenResponse } from "@/lib/oauth"; 5 - import { getCryptoWorker } from "@/lib/worker"; 6 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 3 + import type { OAuthSession, Session } from "@/lib/storage-types" 4 + import type { TokenResponse } from "@/lib/oauth" 5 + import { getCryptoWorker } from "@/lib/worker" 6 + import { IndexedDbStorage } from "@/lib/indexeddb-storage" 7 7 8 8 interface ApiConfig { 9 - pdsUrl: string; 10 - appviewUrl: string; 9 + pdsUrl: string 10 + appviewUrl: string 11 11 } 12 12 13 - const defaultConfig: ApiConfig = { 14 - pdsUrl: import.meta.env.VITE_PDS_URL ?? "https://pds.sans-self.org", 15 - appviewUrl: import.meta.env.VITE_APPVIEW_URL ?? "https://appview.opake.app", 16 - }; 13 + const defaultConfig: Readonly<ApiConfig> = { 14 + pdsUrl: (import.meta.env.VITE_PDS_URL as string | undefined) ?? "https://pds.sans-self.org", 15 + appviewUrl: 16 + (import.meta.env.VITE_APPVIEW_URL as string | undefined) ?? "https://appview.opake.app", 17 + } 17 18 18 19 // --------------------------------------------------------------------------- 19 20 // Unauthenticated XRPC 20 21 // --------------------------------------------------------------------------- 21 22 22 23 interface XrpcParams { 23 - lexicon: string; 24 - method?: "GET" | "POST"; 25 - body?: unknown; 26 - headers?: Record<string, string>; 24 + lexicon: string 25 + method?: "GET" | "POST" 26 + body?: unknown 27 + headers?: Record<string, string> 27 28 } 28 29 29 30 export async function xrpc( 30 31 params: XrpcParams, 31 32 config: ApiConfig = defaultConfig, 32 33 ): Promise<unknown> { 33 - const { lexicon, method = "GET", body, headers = {} } = params; 34 - const url = `${config.pdsUrl}/xrpc/${lexicon}`; 34 + const { lexicon, method = "GET", body, headers = {} } = params 35 + const url = `${config.pdsUrl}/xrpc/${lexicon}` 35 36 36 37 const response = await fetch(url, { 37 38 method, ··· 40 41 ...headers, 41 42 }, 42 43 body: body ? JSON.stringify(body) : undefined, 43 - }); 44 + }) 44 45 45 46 if (!response.ok) { 46 - throw new Error(`XRPC ${lexicon}: ${response.status}`); 47 + throw new Error(`XRPC ${lexicon}: ${response.status}`) 47 48 } 48 49 49 - return response.json(); 50 + return response.json() 50 51 } 51 52 52 53 // --------------------------------------------------------------------------- ··· 54 55 // --------------------------------------------------------------------------- 55 56 56 57 interface AuthenticatedXrpcParams { 57 - pdsUrl: string; 58 - lexicon: string; 59 - method?: "GET" | "POST"; 60 - body?: unknown; 58 + pdsUrl: string 59 + lexicon: string 60 + method?: "GET" | "POST" 61 + body?: unknown 61 62 } 62 63 64 + // eslint-disable-next-line sonarjs/cognitive-complexity -- legitimate retry/nonce dance with nested conditions; splitting would obscure the flow 63 65 export async function authenticatedXrpc( 64 66 params: AuthenticatedXrpcParams, 65 67 session: Session, 66 68 ): Promise<unknown> { 67 - const { pdsUrl, lexicon, method = "GET", body } = params; 68 - const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}`; 69 - const jsonBody = body ? JSON.stringify(body) : undefined; 69 + const { pdsUrl, lexicon, method = "GET", body } = params 70 + const url = `${pdsUrl.replace(/\/$/, "")}/xrpc/${lexicon}` 71 + const jsonBody = body ? JSON.stringify(body) : undefined 70 72 71 73 const headers: Record<string, string> = { 72 74 "Content-Type": "application/json", 73 - }; 75 + } 74 76 75 77 if (session.type === "oauth") { 76 - await attachDpopAuth(headers, session, method, url); 78 + await attachDpopAuth(headers, session, method, url) 77 79 } else { 78 - headers.Authorization = `Bearer ${session.accessJwt}`; 80 + headers.Authorization = `Bearer ${session.accessJwt}` 79 81 } 80 82 81 - let response = await fetch(url, { method, headers, body: jsonBody }); 83 + let response = await fetch(url, { method, headers, body: jsonBody }) 82 84 83 85 // DPoP nonce retry — the PDS has a different nonce than the AS. 84 86 if (session.type === "oauth" && requiresNonceRetry(response)) { 85 - const nonce = response.headers.get("dpop-nonce"); 87 + const nonce = response.headers.get("dpop-nonce") 86 88 if (nonce) { 87 - session.dpopNonce = nonce; 88 - await attachDpopAuth(headers, session, method, url); 89 - response = await fetch(url, { method, headers, body: jsonBody }); 89 + session.dpopNonce = nonce 90 + await attachDpopAuth(headers, session, method, url) 91 + response = await fetch(url, { method, headers, body: jsonBody }) 90 92 } 91 93 } 92 94 93 95 // Token expired — refresh and retry once. 94 96 if (response.status === 401 && session.type === "oauth" && session.refreshToken) { 95 - console.debug("[api] 401 — attempting token refresh"); 96 - const refreshed = await refreshAccessToken(session); 97 + console.debug("[api] 401 — attempting token refresh") 98 + const refreshed = await refreshAccessToken(session) 97 99 if (refreshed) { 98 - await attachDpopAuth(headers, session, method, url); 99 - response = await fetch(url, { method, headers, body: jsonBody }); 100 + await attachDpopAuth(headers, session, method, url) 101 + response = await fetch(url, { method, headers, body: jsonBody }) 100 102 101 103 // The refreshed token might also need a nonce retry on the PDS 102 104 if (requiresNonceRetry(response)) { 103 - const nonce = response.headers.get("dpop-nonce"); 105 + const nonce = response.headers.get("dpop-nonce") 104 106 if (nonce) { 105 - session.dpopNonce = nonce; 106 - await attachDpopAuth(headers, session, method, url); 107 - response = await fetch(url, { method, headers, body: jsonBody }); 107 + session.dpopNonce = nonce 108 + await attachDpopAuth(headers, session, method, url) 109 + response = await fetch(url, { method, headers, body: jsonBody }) 108 110 } 109 111 } 110 112 } 111 113 } 112 114 113 115 if (!response.ok) { 114 - const detail = await response.text().catch(() => ""); 115 - throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()); 116 + const detail = await response.text().catch(() => "") 117 + throw new Error(`XRPC ${lexicon}: ${response.status} ${detail}`.trim()) 116 118 } 117 119 118 - return response.json(); 120 + return response.json() 119 121 } 120 122 121 123 // --------------------------------------------------------------------------- 122 124 // Token refresh 123 125 // --------------------------------------------------------------------------- 124 126 125 - const storage = new IndexedDbStorage(); 127 + const storage = new IndexedDbStorage() 126 128 127 129 /** Refresh an expired OAuth access token. Mutates the session in place and persists to IndexedDB. */ 128 130 async function refreshAccessToken(session: OAuthSession): Promise<boolean> { 129 - const worker = getCryptoWorker(); 130 - const url = session.tokenEndpoint; 131 + const worker = getCryptoWorker() 132 + const url = session.tokenEndpoint 131 133 132 134 const body = new URLSearchParams({ 133 135 grant_type: "refresh_token", 134 136 refresh_token: session.refreshToken, 135 137 client_id: session.clientId, 136 - }); 138 + }) 137 139 138 - const timestamp = Math.floor(Date.now() / 1000); 140 + const timestamp = Math.floor(Date.now() / 1000) 139 141 const proof = await worker.createDpopProof( 140 - session.dpopKey, "POST", url, timestamp, session.dpopNonce, null, 141 - ); 142 + session.dpopKey, 143 + "POST", 144 + url, 145 + timestamp, 146 + session.dpopNonce, 147 + null, 148 + ) 142 149 143 150 const headers: Record<string, string> = { 144 151 "Content-Type": "application/x-www-form-urlencoded", 145 152 DPoP: proof, 146 - }; 153 + } 147 154 148 - let response = await fetch(url, { method: "POST", headers, body: body.toString() }); 149 - let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce; 155 + let response = await fetch(url, { method: "POST", headers, body: body.toString() }) 156 + let nonce = response.headers.get("dpop-nonce") ?? session.dpopNonce 150 157 151 158 // Nonce retry for the AS 152 159 if (response.status === 400) { 153 - const errorBody = await response.clone().json().catch(() => null) as { 154 - error?: string; 155 - } | null; 160 + const errorBody = (await response 161 + .clone() 162 + .json() 163 + .catch(() => null)) as { 164 + error?: string 165 + } | null 156 166 if (errorBody?.error === "use_dpop_nonce" && nonce) { 157 167 const retryProof = await worker.createDpopProof( 158 - session.dpopKey, "POST", url, timestamp, nonce, null, 159 - ); 160 - headers.DPoP = retryProof; 161 - response = await fetch(url, { method: "POST", headers, body: body.toString() }); 162 - nonce = response.headers.get("dpop-nonce") ?? nonce; 168 + session.dpopKey, 169 + "POST", 170 + url, 171 + timestamp, 172 + nonce, 173 + null, 174 + ) 175 + headers.DPoP = retryProof 176 + response = await fetch(url, { method: "POST", headers, body: body.toString() }) 177 + nonce = response.headers.get("dpop-nonce") ?? nonce 163 178 } 164 179 } 165 180 166 181 if (!response.ok) { 167 - console.error("[api] token refresh failed:", response.status); 168 - return false; 182 + console.error("[api] token refresh failed:", response.status) 183 + return false 169 184 } 170 185 171 - const tokenResponse = (await response.json()) as TokenResponse; 172 - console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in); 186 + const tokenResponse = (await response.json()) as TokenResponse 187 + console.debug("[api] token refreshed, new expiry:", tokenResponse.expires_in) 173 188 174 - const now = Math.floor(Date.now() / 1000); 175 - session.accessToken = tokenResponse.access_token; 176 - session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken; 177 - session.dpopNonce = nonce; 178 - session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null; 189 + const now = Math.floor(Date.now() / 1000) 190 + session.accessToken = tokenResponse.access_token 191 + session.refreshToken = tokenResponse.refresh_token ?? session.refreshToken 192 + session.dpopNonce = nonce 193 + session.expiresAt = tokenResponse.expires_in ? now + tokenResponse.expires_in : null 179 194 180 195 // Persist updated session 181 - await storage.saveSession(session.did, session).catch((err) => { 182 - console.warn("[api] failed to persist refreshed session:", err); 183 - }); 196 + await storage.saveSession(session.did, session).catch((err: unknown) => { 197 + console.warn("[api] failed to persist refreshed session:", err) 198 + }) 184 199 185 - return true; 200 + return true 186 201 } 187 202 188 203 /** Check if a response is a DPoP nonce challenge (400 use_dpop_nonce or 401 with nonce header). */ 189 204 function requiresNonceRetry(response: Response): boolean { 190 205 if (response.headers.has("dpop-nonce")) { 191 - if (response.status === 401) return true; 192 - if (response.status === 400) return true; 206 + if (response.status === 401) return true 207 + if (response.status === 400) return true 193 208 } 194 - return false; 209 + return false 195 210 } 196 211 197 212 async function attachDpopAuth( ··· 200 215 method: string, 201 216 url: string, 202 217 ): Promise<void> { 203 - const worker = getCryptoWorker(); 204 - const timestamp = Math.floor(Date.now() / 1000); 218 + const worker = getCryptoWorker() 219 + const timestamp = Math.floor(Date.now() / 1000) 205 220 const proof = await worker.createDpopProof( 206 221 session.dpopKey, 207 222 method, ··· 209 224 timestamp, 210 225 session.dpopNonce, 211 226 session.accessToken, 212 - ); 213 - headers.Authorization = `DPoP ${session.accessToken}`; 214 - headers.DPoP = proof; 227 + ) 228 + headers.Authorization = `DPoP ${session.accessToken}` 229 + headers.DPoP = proof 215 230 } 216 231 217 232 // --------------------------------------------------------------------------- 218 233 // AppView (unauthenticated) 219 234 // --------------------------------------------------------------------------- 220 235 221 - export async function appview( 222 - path: string, 223 - config: ApiConfig = defaultConfig, 224 - ): Promise<unknown> { 225 - const response = await fetch(`${config.appviewUrl}${path}`); 236 + export async function appview(path: string, config: ApiConfig = defaultConfig): Promise<unknown> { 237 + const response = await fetch(`${config.appviewUrl}${path}`) 226 238 227 239 if (!response.ok) { 228 - throw new Error(`AppView ${path}: ${response.status}`); 240 + throw new Error(`AppView ${path}: ${response.status}`) 229 241 } 230 242 231 - return response.json(); 243 + return response.json() 232 244 }
+16 -16
web/src/lib/crypto-types.ts
··· 1 1 export interface AtBytes { 2 - $bytes: string; 2 + $bytes: string 3 3 } 4 4 5 5 export interface WrappedKey { 6 - did: string; 7 - ciphertext: AtBytes; 8 - algo: string; 6 + did: string 7 + ciphertext: AtBytes 8 + algo: string 9 9 } 10 10 11 11 export interface EncryptedPayload { 12 - ciphertext: Uint8Array; 13 - nonce: Uint8Array; 12 + ciphertext: Uint8Array 13 + nonce: Uint8Array 14 14 } 15 15 16 16 // Mirrors: opake-core DpopPublicJwk (client/dpop.rs) 17 17 export interface DpopPublicJwk { 18 - kty: string; 19 - crv: string; 20 - x: string; 21 - y: string; 18 + kty: string 19 + crv: string 20 + x: string 21 + y: string 22 22 } 23 23 24 24 // Mirrors: opake-core DpopKeyPair (client/dpop.rs) 25 25 // Serialized via serde — field names match Rust's #[serde(rename)] 26 26 export interface DpopKeyPair { 27 - privateKey: string; // base64url P-256 secret 28 - publicJwk: DpopPublicJwk; 27 + privateKey: string // base64url P-256 secret 28 + publicJwk: DpopPublicJwk 29 29 } 30 30 31 31 // Mirrors: opake-core PkceChallenge (client/oauth_discovery.rs) 32 32 export interface PkceChallenge { 33 - verifier: string; 34 - challenge: string; 33 + verifier: string 34 + challenge: string 35 35 } 36 36 37 37 // Mirrors: opake-core EphemeralKeypair (crypto/mod.rs) 38 38 export interface EphemeralKeypair { 39 - publicKey: Uint8Array; 40 - privateKey: Uint8Array; 39 + publicKey: Uint8Array 40 + privateKey: Uint8Array 41 41 }
+13 -13
web/src/lib/encoding.ts
··· 2 2 3 3 /** Standard base64 from bytes. */ 4 4 export function uint8ArrayToBase64(bytes: Uint8Array): string { 5 - let binary = ""; 5 + let binary = "" 6 6 for (const byte of bytes) { 7 - binary += String.fromCharCode(byte); 7 + binary += String.fromCharCode(byte) 8 8 } 9 - return btoa(binary); 9 + return btoa(binary) 10 10 } 11 11 12 12 /** Base64 to bytes — handles unpadded strings (PDS returns unpadded). */ 13 13 export function base64ToUint8Array(b64: string): Uint8Array { 14 14 // Pad to multiple of 4 15 - const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4); 16 - const binary = atob(padded); 17 - const bytes = new Uint8Array(binary.length); 15 + const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4) 16 + const binary = atob(padded) 17 + const bytes = new Uint8Array(binary.length) 18 18 for (let i = 0; i < binary.length; i++) { 19 - bytes[i] = binary.charCodeAt(i); 19 + bytes[i] = binary.charCodeAt(i) 20 20 } 21 - return bytes; 21 + return bytes 22 22 } 23 23 24 24 /** First 8 bytes of a public key as a colon-separated hex fingerprint. */ 25 25 export function formatFingerprint(pubkey: Uint8Array): string { 26 26 return Array.from(pubkey.slice(0, 8)) 27 27 .map((b) => b.toString(16).padStart(2, "0")) 28 - .join(":"); 28 + .join(":") 29 29 } 30 30 31 31 /** Extract the rkey from an AT-URI: `at://did/collection/rkey` → `rkey`. */ 32 32 export function rkeyFromUri(atUri: string): string { 33 - const parts = atUri.split("/"); 34 - const rkey = parts.at(-1); 35 - if (!rkey) throw new Error(`Invalid AT-URI: ${atUri}`); 36 - return rkey; 33 + const parts = atUri.split("/") 34 + const rkey = parts.at(-1) 35 + if (!rkey) throw new Error(`Invalid AT-URI: ${atUri}`) 36 + return rkey 37 37 }
+55 -46
web/src/lib/indexeddb-storage.ts
··· 1 1 // IndexedDB-backed Storage implementation using Dexie.js. 2 2 // Mirrors: crates/opake-cli/src/config.rs — FileStorage (but for the browser) 3 3 4 - import Dexie, { type EntityTable } from "dexie"; 5 - import type { Config, Identity, Session } from "./storage-types"; 6 - import { type Storage, StorageError, sanitizeDid } from "./storage"; 4 + import Dexie, { type EntityTable } from "dexie" 5 + import type { Config, Identity, Session } from "./storage-types" 6 + import { type Storage, StorageError, sanitizeDid } from "./storage" 7 7 8 - const CONFIG_KEY = "global"; 8 + const CONFIG_KEY = "global" 9 9 10 10 interface ConfigRow { 11 - key: string; 12 - value: Config; 11 + key: string 12 + value: Config 13 13 } 14 14 15 15 interface IdentityRow { 16 - did: string; 17 - value: Identity; 16 + did: string 17 + value: Identity 18 18 } 19 19 20 20 interface SessionRow { 21 - did: string; 22 - value: Session; 21 + did: string 22 + value: Session 23 23 } 24 24 25 25 class OpakeDatabase extends Dexie { 26 - configs!: EntityTable<ConfigRow, "key">; 27 - identities!: EntityTable<IdentityRow, "did">; 28 - sessions!: EntityTable<SessionRow, "did">; 26 + readonly configs!: Readonly<EntityTable<ConfigRow, "key">> 27 + readonly identities!: Readonly<EntityTable<IdentityRow, "did">> 28 + readonly sessions!: Readonly<EntityTable<SessionRow, "did">> 29 29 30 30 constructor(name = "opake") { 31 - super(name); 31 + super(name) 32 32 this.version(1).stores({ 33 33 configs: "key", 34 34 identities: "did", 35 35 sessions: "did", 36 - }); 36 + }) 37 37 } 38 38 } 39 39 40 40 export class IndexedDbStorage implements Storage { 41 - private db: OpakeDatabase; 41 + private readonly db: Readonly<OpakeDatabase> 42 42 43 43 constructor(dbName = "opake") { 44 - this.db = new OpakeDatabase(dbName); 44 + this.db = new OpakeDatabase(dbName) 45 45 } 46 46 47 47 async loadConfig(): Promise<Config> { 48 - const row = await this.db.configs.get(CONFIG_KEY); 48 + const row = await this.db.configs.get(CONFIG_KEY) 49 49 if (!row) { 50 - throw new StorageError("no config found — log in first"); 50 + throw new StorageError("no config found — log in first") 51 51 } 52 - return row.value; 52 + return row.value 53 53 } 54 54 55 55 async saveConfig(config: Config): Promise<void> { 56 - await this.db.configs.put({ key: CONFIG_KEY, value: config }); 56 + await this.db.configs.put({ key: CONFIG_KEY, value: config }) 57 57 } 58 58 59 59 async loadIdentity(did: string): Promise<Identity> { 60 - const key = sanitizeDid(did); 61 - const row = await this.db.identities.get(key); 60 + const key = sanitizeDid(did) 61 + const row = await this.db.identities.get(key) 62 62 if (!row) { 63 - throw new StorageError(`no identity for ${did} — log in first`); 63 + throw new StorageError(`no identity for ${did} — log in first`) 64 64 } 65 - return row.value; 65 + return row.value 66 66 } 67 67 68 68 async saveIdentity(did: string, identity: Identity): Promise<void> { 69 - const key = sanitizeDid(did); 70 - await this.db.identities.put({ did: key, value: identity }); 69 + const key = sanitizeDid(did) 70 + await this.db.identities.put({ did: key, value: identity }) 71 71 } 72 72 73 73 async loadSession(did: string): Promise<Session> { 74 - const key = sanitizeDid(did); 75 - const row = await this.db.sessions.get(key); 74 + const key = sanitizeDid(did) 75 + const row = await this.db.sessions.get(key) 76 76 if (!row) { 77 - throw new StorageError(`no session for ${did} — log in first`); 77 + throw new StorageError(`no session for ${did} — log in first`) 78 78 } 79 - return row.value; 79 + return row.value 80 80 } 81 81 82 82 async saveSession(did: string, session: Session): Promise<void> { 83 - const key = sanitizeDid(did); 84 - await this.db.sessions.put({ did: key, value: session }); 83 + const key = sanitizeDid(did) 84 + await this.db.sessions.put({ did: key, value: session }) 85 85 } 86 86 87 87 async removeAccount(did: string): Promise<void> { 88 - const config = await this.loadConfig(); 89 - delete config.accounts[did]; 90 - if (config.defaultDid === did) { 91 - const remaining = Object.keys(config.accounts); 92 - config.defaultDid = remaining.length > 0 ? remaining[0]! : null; 88 + const config = await this.loadConfig() 89 + const remainingAccounts = Object.fromEntries( 90 + Object.entries(config.accounts).filter(([key]) => key !== did), 91 + ) 92 + const remaining = Object.keys(remainingAccounts) 93 + const updatedConfig: Config = { 94 + ...config, 95 + accounts: remainingAccounts, 96 + defaultDid: 97 + config.defaultDid === did 98 + ? remaining.length > 0 99 + ? remaining[0] 100 + : null 101 + : config.defaultDid, 93 102 } 94 - const key = sanitizeDid(did); 103 + const key = sanitizeDid(did) 95 104 await this.db.transaction( 96 105 "rw", 97 106 [this.db.configs, this.db.identities, this.db.sessions], 98 107 async () => { 99 - await this.db.configs.put({ key: CONFIG_KEY, value: config }); 100 - await this.db.identities.delete(key); 101 - await this.db.sessions.delete(key); 108 + await this.db.configs.put({ key: CONFIG_KEY, value: updatedConfig }) 109 + await this.db.identities.delete(key) 110 + await this.db.sessions.delete(key) 102 111 }, 103 - ); 112 + ) 104 113 } 105 114 106 115 /** Close the database connection. Useful for test cleanup. */ 107 116 close(): void { 108 - this.db.close(); 117 + this.db.close() 109 118 } 110 119 111 120 /** Delete the entire database. Useful for test cleanup. */ 112 121 async destroy(): Promise<void> { 113 - this.db.close(); 114 - await this.db.delete(); 122 + this.db.close() 123 + await this.db.delete() 115 124 } 116 125 }
+156 -138
web/src/lib/oauth.ts
··· 3 3 // HTTP calls use plain fetch. Crypto (DPoP proofs, PKCE, keypair gen) is 4 4 // delegated to the WASM worker via the CryptoWorker type. 5 5 6 - import type { Remote } from "comlink"; 7 - import type { CryptoApi } from "@/workers/crypto.worker"; 8 - import type { DpopKeyPair } from "@/lib/crypto-types"; 6 + import type { Remote } from "comlink" 7 + import type { CryptoApi } from "@/workers/crypto.worker" 8 + import type { DpopKeyPair } from "@/lib/crypto-types" 9 9 10 - type CryptoWorker = Remote<CryptoApi>; 10 + type CryptoWorker = Remote<CryptoApi> 11 11 12 12 // --------------------------------------------------------------------------- 13 13 // Types 14 14 // --------------------------------------------------------------------------- 15 15 16 16 export interface AuthorizationServerMetadata { 17 - issuer: string; 18 - authorization_endpoint: string; 19 - token_endpoint: string; 20 - pushed_authorization_request_endpoint?: string; 21 - scopes_supported: string[]; 22 - response_types_supported: string[]; 23 - grant_types_supported: string[]; 24 - code_challenge_methods_supported: string[]; 25 - dpop_signing_alg_values_supported: string[]; 26 - token_endpoint_auth_methods_supported: string[]; 27 - require_pushed_authorization_requests: boolean; 17 + issuer: string 18 + authorization_endpoint: string 19 + token_endpoint: string 20 + pushed_authorization_request_endpoint?: string 21 + scopes_supported: string[] 22 + response_types_supported: string[] 23 + grant_types_supported: string[] 24 + code_challenge_methods_supported: string[] 25 + dpop_signing_alg_values_supported: string[] 26 + token_endpoint_auth_methods_supported: string[] 27 + require_pushed_authorization_requests: boolean 28 28 } 29 29 30 30 export interface TokenResponse { 31 - access_token: string; 32 - token_type: string; 33 - refresh_token?: string; 34 - expires_in?: number; 35 - scope?: string; 36 - sub?: string; 31 + access_token: string 32 + token_type: string 33 + refresh_token?: string 34 + expires_in?: number 35 + scope?: string 36 + sub?: string 37 37 } 38 38 39 39 export interface OAuthPendingState { 40 - pdsUrl: string; 41 - handle: string; 42 - dpopKey: DpopKeyPair; 43 - pkceVerifier: string; 44 - csrfState: string; 45 - tokenEndpoint: string; 46 - clientId: string; 47 - dpopNonce: string | null; 40 + pdsUrl: string 41 + handle: string 42 + dpopKey: DpopKeyPair 43 + pkceVerifier: string 44 + csrfState: string 45 + tokenEndpoint: string 46 + clientId: string 47 + dpopNonce: string | null 48 48 } 49 49 50 - const PENDING_STATE_KEY = "opake:oauth_pending"; 51 - const BSKY_PUBLIC_API = "https://public.api.bsky.app"; 50 + const PENDING_STATE_KEY = "opake:oauth_pending" 51 + const BSKY_PUBLIC_API = "https://public.api.bsky.app" 52 52 53 53 // --------------------------------------------------------------------------- 54 54 // Handle → PDS resolution 55 55 // --------------------------------------------------------------------------- 56 56 57 - export async function resolveHandleToPds( 58 - handle: string, 59 - ): Promise<{ did: string; pdsUrl: string }> { 60 - const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 61 - const response = await fetch(resolveUrl); 57 + export async function resolveHandleToPds(handle: string): Promise<{ did: string; pdsUrl: string }> { 58 + const resolveUrl = `${BSKY_PUBLIC_API}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 59 + const response = await fetch(resolveUrl) 62 60 if (!response.ok) { 63 - throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`); 61 + throw new Error(`Failed to resolve handle "${handle}": HTTP ${response.status}`) 64 62 } 65 - const { did } = (await response.json()) as { did: string }; 63 + const { did } = (await response.json()) as { did: string } 66 64 67 - const pdsUrl = await pdsUrlFromDid(did); 68 - return { did, pdsUrl }; 65 + const pdsUrl = await pdsUrlFromDid(did) 66 + return { did, pdsUrl } 69 67 } 70 68 71 69 async function pdsUrlFromDid(did: string): Promise<string> { ··· 73 71 ? `https://plc.directory/${did}` 74 72 : did.startsWith("did:web:") 75 73 ? `https://${did.slice("did:web:".length)}/.well-known/did.json` 76 - : null; 74 + : null 77 75 78 - if (!docUrl) throw new Error(`Unsupported DID method: ${did}`); 76 + if (!docUrl) throw new Error(`Unsupported DID method: ${did}`) 79 77 80 - const response = await fetch(docUrl); 78 + const response = await fetch(docUrl) 81 79 if (!response.ok) { 82 - throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`); 80 + throw new Error(`Failed to fetch DID document for ${did}: HTTP ${response.status}`) 83 81 } 84 82 85 83 const doc = (await response.json()) as { 86 - service?: Array<{ id: string; serviceEndpoint: string }>; 87 - }; 84 + service?: { id: string; serviceEndpoint: string }[] 85 + } 88 86 89 - const pds = doc.service?.find((s) => s.id === "#atproto_pds"); 90 - if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`); 87 + const pds = doc.service?.find((s) => s.id === "#atproto_pds") 88 + if (!pds) throw new Error(`No #atproto_pds service in DID document for ${did}`) 91 89 92 - return pds.serviceEndpoint; 90 + return pds.serviceEndpoint 93 91 } 94 92 95 93 // --------------------------------------------------------------------------- ··· 99 97 export async function discoverAuthorizationServer( 100 98 pdsUrl: string, 101 99 ): Promise<AuthorizationServerMetadata> { 102 - const base = pdsUrl.replace(/\/$/, ""); 100 + const base = pdsUrl.replace(/\/$/, "") 103 101 104 - const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`); 102 + const prmResponse = await fetch(`${base}/.well-known/oauth-protected-resource`) 105 103 if (!prmResponse.ok) { 106 - throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`); 104 + throw new Error(`PDS does not support OAuth (HTTP ${prmResponse.status})`) 107 105 } 108 106 const prm = (await prmResponse.json()) as { 109 - authorization_servers?: string[]; 110 - }; 107 + authorization_servers?: string[] 108 + } 111 109 112 - const asUrl = prm.authorization_servers?.[0]; 113 - if (!asUrl) throw new Error("No authorization servers in protected resource metadata"); 110 + const asUrl = prm.authorization_servers?.[0] 111 + if (!asUrl) throw new Error("No authorization servers in protected resource metadata") 114 112 115 - const asBase = asUrl.replace(/\/$/, ""); 116 - const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`); 113 + const asBase = asUrl.replace(/\/$/, "") 114 + const asmResponse = await fetch(`${asBase}/.well-known/oauth-authorization-server`) 117 115 if (!asmResponse.ok) { 118 - throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`); 116 + throw new Error(`Failed to fetch AS metadata: HTTP ${asmResponse.status}`) 119 117 } 120 118 121 - return (await asmResponse.json()) as AuthorizationServerMetadata; 119 + return (await asmResponse.json()) as AuthorizationServerMetadata 122 120 } 123 121 124 122 // --------------------------------------------------------------------------- ··· 126 124 // --------------------------------------------------------------------------- 127 125 128 126 export function buildClientId(redirectUri: string): string { 129 - return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}`; 127 + return `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}` 130 128 } 131 129 132 130 export function buildRedirectUri(): string { 133 - return `${window.location.origin}/oauth/callback`; 131 + return `${window.location.origin}/oauth/callback` 134 132 } 135 133 136 134 // --------------------------------------------------------------------------- ··· 146 144 accessToken: string | null, 147 145 worker: CryptoWorker, 148 146 ): Promise<{ response: Response; dpopNonce: string | null }> { 149 - console.debug("[dpop] creating proof for", method, url); 150 - const timestamp = Math.floor(Date.now() / 1000); 147 + console.debug("[dpop] creating proof for", method, url) 148 + const timestamp = Math.floor(Date.now() / 1000) 151 149 const proof = await worker.createDpopProof( 152 - dpopKey, method, url, timestamp, dpopNonce, accessToken, 153 - ); 154 - console.debug("[dpop] proof created, sending request"); 150 + dpopKey, 151 + method, 152 + url, 153 + timestamp, 154 + dpopNonce, 155 + accessToken, 156 + ) 157 + console.debug("[dpop] proof created, sending request") 155 158 156 159 const headers: Record<string, string> = { 157 160 "Content-Type": "application/x-www-form-urlencoded", 158 161 DPoP: proof, 159 - }; 162 + } 160 163 if (accessToken) { 161 - headers.Authorization = `DPoP ${accessToken}`; 164 + headers.Authorization = `DPoP ${accessToken}` 162 165 } 163 166 164 - let response = await fetch(url, { method, headers, body: body.toString() }); 165 - console.debug("[dpop] response:", response.status); 166 - let nonce = response.headers.get("dpop-nonce") ?? dpopNonce; 167 + let response = await fetch(url, { method, headers, body: body.toString() }) 168 + console.debug("[dpop] response:", response.status) 169 + let nonce = response.headers.get("dpop-nonce") ?? dpopNonce 167 170 168 171 // Retry on use_dpop_nonce 169 172 if (response.status === 400) { 170 - const errorBody = await response.clone().json().catch(() => null) as { 171 - error?: string; 172 - error_description?: string; 173 - } | null; 174 - console.debug("[dpop] 400 error body:", errorBody); 173 + const errorBody = (await response 174 + .clone() 175 + .json() 176 + .catch(() => null)) as { 177 + error?: string 178 + error_description?: string 179 + } | null 180 + console.debug("[dpop] 400 error body:", errorBody) 175 181 176 182 if (errorBody?.error === "use_dpop_nonce" && nonce) { 177 - console.debug("[dpop] retrying with server nonce"); 183 + console.debug("[dpop] retrying with server nonce") 178 184 const retryProof = await worker.createDpopProof( 179 - dpopKey, method, url, timestamp, nonce, accessToken, 180 - ); 181 - headers.DPoP = retryProof; 182 - response = await fetch(url, { method, headers, body: body.toString() }); 183 - console.debug("[dpop] retry response:", response.status); 184 - nonce = response.headers.get("dpop-nonce") ?? nonce; 185 + dpopKey, 186 + method, 187 + url, 188 + timestamp, 189 + nonce, 190 + accessToken, 191 + ) 192 + headers.DPoP = retryProof 193 + response = await fetch(url, { method, headers, body: body.toString() }) 194 + console.debug("[dpop] retry response:", response.status) 195 + nonce = response.headers.get("dpop-nonce") ?? nonce 185 196 } 186 197 } 187 198 188 - return { response, dpopNonce: nonce }; 199 + return { response, dpopNonce: nonce } 189 200 } 190 201 191 202 // --------------------------------------------------------------------------- ··· 210 221 state, 211 222 code_challenge: pkceChallenge, 212 223 code_challenge_method: "S256", 213 - }); 224 + }) 214 225 215 226 const { response, dpopNonce: nonce } = await fetchWithDpop( 216 - parEndpoint, "POST", body, dpopKey, dpopNonce, null, worker, 217 - ); 227 + parEndpoint, 228 + "POST", 229 + body, 230 + dpopKey, 231 + dpopNonce, 232 + null, 233 + worker, 234 + ) 218 235 219 236 if (!response.ok) { 220 - const err = await response.json().catch(() => ({})) as { 221 - error?: string; 222 - error_description?: string; 223 - }; 237 + const err = (await response.json().catch(() => ({}))) as { 238 + error?: string 239 + error_description?: string 240 + } 224 241 throw new Error( 225 242 `PAR failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 226 - ); 243 + ) 227 244 } 228 245 229 - const par = (await response.json()) as { request_uri: string; expires_in: number }; 230 - return { requestUri: par.request_uri, expiresIn: par.expires_in, dpopNonce: nonce }; 246 + const par = (await response.json()) as { request_uri: string; expires_in: number } 247 + return { requestUri: par.request_uri, expiresIn: par.expires_in, dpopNonce: nonce } 231 248 } 232 249 233 250 // --------------------------------------------------------------------------- ··· 239 256 clientId: string, 240 257 requestUri: string, 241 258 ): string { 242 - return `${authorizationEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(requestUri)}`; 259 + return `${authorizationEndpoint}?client_id=${encodeURIComponent(clientId)}&request_uri=${encodeURIComponent(requestUri)}` 243 260 } 244 261 245 262 // --------------------------------------------------------------------------- ··· 262 279 code, 263 280 redirect_uri: redirectUri, 264 281 code_verifier: pkceVerifier, 265 - }); 282 + }) 266 283 267 284 const { response, dpopNonce: nonce } = await fetchWithDpop( 268 - tokenEndpoint, "POST", body, dpopKey, dpopNonce, null, worker, 269 - ); 285 + tokenEndpoint, 286 + "POST", 287 + body, 288 + dpopKey, 289 + dpopNonce, 290 + null, 291 + worker, 292 + ) 270 293 271 294 if (!response.ok) { 272 - const err = await response.json().catch(() => ({})) as { 273 - error?: string; 274 - error_description?: string; 275 - }; 295 + const err = (await response.json().catch(() => ({}))) as { 296 + error?: string 297 + error_description?: string 298 + } 276 299 throw new Error( 277 300 `Token exchange failed: ${err.error ?? "unknown"}: ${err.error_description ?? `HTTP ${response.status}`}`, 278 - ); 301 + ) 279 302 } 280 303 281 - const tokenResponse = (await response.json()) as TokenResponse; 304 + const tokenResponse = (await response.json()) as TokenResponse 282 305 283 306 if (tokenResponse.token_type.toLowerCase() !== "dpop") { 284 - throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`); 307 + throw new Error(`Expected token_type "DPoP", got "${tokenResponse.token_type}"`) 285 308 } 286 309 287 - return { tokenResponse, dpopNonce: nonce }; 310 + return { tokenResponse, dpopNonce: nonce } 288 311 } 289 312 290 313 // --------------------------------------------------------------------------- ··· 301 324 dpopNonce: string | null, 302 325 worker: CryptoWorker, 303 326 ): Promise<void> { 304 - const base = pdsUrl.replace(/\/$/, ""); 305 - const url = `${base}/xrpc/com.atproto.repo.putRecord`; 327 + const base = pdsUrl.replace(/\/$/, "") 328 + const url = `${base}/xrpc/com.atproto.repo.putRecord` 306 329 307 - const record: Record<string, unknown> = { 330 + const record: Readonly<Record<string, unknown>> = { 308 331 $type: "app.opake.publicKey", 309 332 opakeVersion: 1, 310 333 algo: "x25519", 311 334 publicKey: { $bytes: publicKey }, 312 335 createdAt: new Date().toISOString(), 313 - }; 314 - if (verifyKey) { 315 - record.signingKey = { $bytes: verifyKey }; 316 - record.signingAlgo = "ed25519"; 336 + ...(verifyKey ? { signingKey: { $bytes: verifyKey }, signingAlgo: "ed25519" } : {}), 317 337 } 318 338 319 339 const jsonBody = JSON.stringify({ ··· 321 341 collection: "app.opake.publicKey", 322 342 rkey: "self", 323 343 record, 324 - }); 344 + }) 325 345 326 346 const makeHeaders = async (nonce: string | null): Promise<Record<string, string>> => { 327 - const timestamp = Math.floor(Date.now() / 1000); 328 - const proof = await worker.createDpopProof( 329 - dpopKey, "POST", url, timestamp, nonce, accessToken, 330 - ); 347 + const timestamp = Math.floor(Date.now() / 1000) 348 + const proof = await worker.createDpopProof(dpopKey, "POST", url, timestamp, nonce, accessToken) 331 349 return { 332 350 "Content-Type": "application/json", 333 351 Authorization: `DPoP ${accessToken}`, 334 352 DPoP: proof, 335 - }; 336 - }; 353 + } 354 + } 337 355 338 - let headers = await makeHeaders(dpopNonce); 339 - let response = await fetch(url, { method: "POST", headers, body: jsonBody }); 356 + let headers = await makeHeaders(dpopNonce) 357 + let response = await fetch(url, { method: "POST", headers, body: jsonBody }) 340 358 341 359 // DPoP nonce retry — PDS nonce differs from AS nonce 342 360 if ((response.status === 401 || response.status === 400) && response.headers.has("dpop-nonce")) { 343 - const nonce = response.headers.get("dpop-nonce"); 344 - headers = await makeHeaders(nonce); 345 - response = await fetch(url, { method: "POST", headers, body: jsonBody }); 361 + const nonce = response.headers.get("dpop-nonce") 362 + headers = await makeHeaders(nonce) 363 + response = await fetch(url, { method: "POST", headers, body: jsonBody }) 346 364 } 347 365 348 366 if (!response.ok) { 349 - const body = await response.text().catch(() => ""); 350 - throw new Error(`Failed to publish public key: HTTP ${response.status} ${body}`); 367 + const body = await response.text().catch(() => "") 368 + throw new Error(`Failed to publish public key: HTTP ${response.status} ${body}`) 351 369 } 352 370 } 353 371 ··· 356 374 // --------------------------------------------------------------------------- 357 375 358 376 export function savePendingState(state: OAuthPendingState): void { 359 - sessionStorage.setItem(PENDING_STATE_KEY, JSON.stringify(state)); 377 + sessionStorage.setItem(PENDING_STATE_KEY, JSON.stringify(state)) 360 378 } 361 379 362 380 export function loadPendingState(): OAuthPendingState | null { 363 - const raw = sessionStorage.getItem(PENDING_STATE_KEY); 364 - if (!raw) return null; 365 - return JSON.parse(raw) as OAuthPendingState; 381 + const raw = sessionStorage.getItem(PENDING_STATE_KEY) 382 + if (!raw) return null 383 + return JSON.parse(raw) as OAuthPendingState 366 384 } 367 385 368 386 export function clearPendingState(): void { 369 - sessionStorage.removeItem(PENDING_STATE_KEY); 387 + sessionStorage.removeItem(PENDING_STATE_KEY) 370 388 } 371 389 372 390 // --------------------------------------------------------------------------- ··· 374 392 // --------------------------------------------------------------------------- 375 393 376 394 export function generateCsrfState(): string { 377 - const bytes = new Uint8Array(16); 378 - crypto.getRandomValues(bytes); 395 + const bytes = new Uint8Array(16) 396 + crypto.getRandomValues(bytes) 379 397 return btoa(String.fromCharCode(...bytes)) 380 - .replace(/\+/g, "-") 381 - .replace(/\//g, "_") 382 - .replace(/=+$/, ""); 398 + .replaceAll("+", "-") 399 + .replaceAll("/", "_") 400 + .replaceAll("=", "") 383 401 }
+77 -73
web/src/lib/pairing.ts
··· 1 1 // Device pairing XRPC orchestration. 2 2 // Consumes authenticatedXrpc from api.ts and crypto worker functions. 3 3 4 - import type { Remote } from "comlink"; 5 - import type { CryptoApi } from "@/workers/crypto.worker"; 6 - import type { WrappedKey, AtBytes } from "@/lib/crypto-types"; 7 - import type { Identity, Session } from "@/lib/storage-types"; 8 - import { authenticatedXrpc } from "@/lib/api"; 4 + import type { Remote } from "comlink" 5 + import type { CryptoApi } from "@/workers/crypto.worker" 6 + import type { WrappedKey, AtBytes } from "@/lib/crypto-types" 7 + import type { Identity, Session } from "@/lib/storage-types" 8 + import { authenticatedXrpc } from "@/lib/api" 9 9 import { 10 10 uint8ArrayToBase64, 11 11 base64ToUint8Array, 12 12 formatFingerprint, 13 13 rkeyFromUri, 14 - } from "@/lib/encoding"; 14 + } from "@/lib/encoding" 15 15 16 16 // --------------------------------------------------------------------------- 17 17 // Types 18 18 // --------------------------------------------------------------------------- 19 19 20 20 export interface PendingPairRequest { 21 - uri: string; 22 - fingerprint: string; 23 - createdAt: string; 24 - ephemeralKey: Uint8Array; 21 + uri: string 22 + fingerprint: string 23 + createdAt: string 24 + ephemeralKey: Uint8Array 25 25 } 26 26 27 27 interface PairResponseRecord { 28 - wrappedKey: WrappedKey; 29 - ciphertext: AtBytes; 30 - nonce: AtBytes; 28 + wrappedKey: WrappedKey 29 + ciphertext: AtBytes 30 + nonce: AtBytes 31 31 } 32 32 33 - const PAIR_REQUEST_COLLECTION = "app.opake.pairRequest"; 34 - const PAIR_RESPONSE_COLLECTION = "app.opake.pairResponse"; 35 - const SCHEMA_VERSION = 1; 33 + const PAIR_REQUEST_COLLECTION = "app.opake.pairRequest" 34 + const PAIR_RESPONSE_COLLECTION = "app.opake.pairResponse" 35 + const SCHEMA_VERSION = 1 36 36 37 37 // --------------------------------------------------------------------------- 38 38 // Create pair request (new device) ··· 45 45 ephemeralPubKey: Uint8Array, 46 46 session: Session, 47 47 ): Promise<string> { 48 - const rkey = generateTid(); 48 + const rkey = generateTid() 49 49 50 50 const record = { 51 51 $type: PAIR_REQUEST_COLLECTION, ··· 53 53 ephemeralKey: { $bytes: uint8ArrayToBase64(ephemeralPubKey) }, 54 54 algo: "x25519", 55 55 createdAt: new Date().toISOString(), 56 - }; 56 + } as const 57 57 58 58 const result = (await authenticatedXrpc( 59 59 { ··· 68 68 }, 69 69 }, 70 70 session, 71 - )) as { uri: string }; 71 + )) as { uri: string } 72 72 73 - return result.uri; 73 + return result.uri 74 74 } 75 75 76 76 // --------------------------------------------------------------------------- ··· 90 90 }, 91 91 session, 92 92 )) as { 93 - records: Array<{ 94 - uri: string; 93 + records: { 94 + uri: string 95 95 value: { 96 - ephemeralKey: AtBytes; 97 - createdAt: string; 98 - }; 99 - }>; 100 - }; 96 + ephemeralKey: AtBytes 97 + createdAt: string 98 + } 99 + }[] 100 + } 101 101 102 102 return result.records.map((rec) => { 103 - const keyBytes = base64ToUint8Array(rec.value.ephemeralKey.$bytes); 103 + const keyBytes = base64ToUint8Array(rec.value.ephemeralKey.$bytes) 104 104 return { 105 105 uri: rec.uri, 106 106 fingerprint: formatFingerprint(keyBytes), 107 107 createdAt: rec.value.createdAt, 108 108 ephemeralKey: keyBytes, 109 - }; 110 - }); 109 + } 110 + }) 111 111 } 112 112 113 113 // --------------------------------------------------------------------------- ··· 128 128 }, 129 129 session, 130 130 )) as { 131 - records: Array<{ 132 - uri: string; 131 + records: { 132 + uri: string 133 133 value: { 134 - request: string; 135 - wrappedKey: WrappedKey; 136 - ciphertext: AtBytes; 137 - nonce: AtBytes; 138 - }; 139 - }>; 140 - }; 134 + request: string 135 + wrappedKey: WrappedKey 136 + ciphertext: AtBytes 137 + nonce: AtBytes 138 + } 139 + }[] 140 + } 141 141 142 142 // Find the response that references our request 143 - const requestUri = `at://${did}/${PAIR_REQUEST_COLLECTION}/${requestRkey}`; 144 - const match = result.records.find((rec) => rec.value.request === requestUri); 145 - if (!match) return null; 143 + const requestUri = `at://${did}/${PAIR_REQUEST_COLLECTION}/${requestRkey}` 144 + const match = result.records.find((rec) => rec.value.request === requestUri) 145 + if (!match) return null 146 146 147 147 return { 148 148 wrappedKey: match.value.wrappedKey, 149 149 ciphertext: match.value.ciphertext, 150 150 nonce: match.value.nonce, 151 - }; 151 + } 152 152 } 153 153 154 154 // --------------------------------------------------------------------------- ··· 161 161 worker: Remote<CryptoApi>, 162 162 ): Promise<Identity> { 163 163 // Unwrap the content key using the ephemeral private key 164 - const contentKey = await worker.unwrapKey(response.wrappedKey, ephemeralPrivKey); 164 + const contentKey = await worker.unwrapKey(response.wrappedKey, ephemeralPrivKey) 165 165 166 166 // Decrypt the identity JSON 167 - const ciphertext = base64ToUint8Array(response.ciphertext.$bytes); 168 - const nonce = base64ToUint8Array(response.nonce.$bytes); 169 - const plaintext = await worker.decryptBlob(contentKey, ciphertext, nonce); 167 + const ciphertext = base64ToUint8Array(response.ciphertext.$bytes) 168 + const nonce = base64ToUint8Array(response.nonce.$bytes) 169 + const plaintext = await worker.decryptBlob(contentKey, ciphertext, nonce) 170 170 171 171 // Parse the identity (mirrors Rust's serde_json::from_slice) 172 - const decoder = new TextDecoder(); 173 - const identity = JSON.parse(decoder.decode(plaintext)) as Identity; 172 + const decoder = new TextDecoder() 173 + const identity = JSON.parse(decoder.decode(plaintext)) as Identity 174 174 175 - return identity; 175 + return identity 176 176 } 177 177 178 178 // --------------------------------------------------------------------------- ··· 189 189 worker: Remote<CryptoApi>, 190 190 ): Promise<string> { 191 191 // Generate a content key for encrypting the identity 192 - const contentKey = await worker.generateContentKey(); 192 + const contentKey = await worker.generateContentKey() 193 193 194 194 // Serialize identity to JSON (mirrors Rust's serde_json::to_vec) 195 - const encoder = new TextEncoder(); 196 - const plaintext = encoder.encode(JSON.stringify(identity)); 195 + const encoder = new TextEncoder() 196 + const plaintext = encoder.encode(JSON.stringify(identity)) 197 197 198 198 // Encrypt identity with the content key 199 - const encrypted = await worker.encryptBlob(contentKey, plaintext); 199 + const encrypted = await worker.encryptBlob(contentKey, plaintext) 200 200 201 201 // Wrap the content key to the requester's ephemeral public key 202 - const wrappedKey = await worker.wrapKey(contentKey, ephemeralPubKey, did); 202 + const wrappedKey = await worker.wrapKey(contentKey, ephemeralPubKey, did) 203 203 204 - const rkey = generateTid(); 204 + const rkey = generateTid() 205 205 206 206 const record = { 207 207 $type: PAIR_RESPONSE_COLLECTION, ··· 212 212 nonce: { $bytes: uint8ArrayToBase64(encrypted.nonce) }, 213 213 algo: "aes-256-gcm", 214 214 createdAt: new Date().toISOString(), 215 - }; 215 + } as const 216 216 217 217 const result = (await authenticatedXrpc( 218 218 { ··· 227 227 }, 228 228 }, 229 229 session, 230 - )) as { uri: string }; 230 + )) as { uri: string } 231 231 232 - return result.uri; 232 + return result.uri 233 233 } 234 234 235 235 // --------------------------------------------------------------------------- ··· 244 244 session: Session, 245 245 ): Promise<void> { 246 246 const deleteRecord = async (collection: string, uri: string) => { 247 - const rkey = rkeyFromUri(uri); 247 + const rkey = rkeyFromUri(uri) 248 248 await authenticatedXrpc( 249 249 { 250 250 pdsUrl, ··· 253 253 body: { repo: did, collection, rkey }, 254 254 }, 255 255 session, 256 - ); 257 - }; 256 + ) 257 + } 258 258 259 - await deleteRecord(PAIR_REQUEST_COLLECTION, requestUri); 259 + await deleteRecord(PAIR_REQUEST_COLLECTION, requestUri) 260 260 if (responseUri) { 261 - await deleteRecord(PAIR_RESPONSE_COLLECTION, responseUri); 261 + await deleteRecord(PAIR_RESPONSE_COLLECTION, responseUri) 262 262 } 263 263 } 264 264 ··· 266 266 // TID generation (AT Protocol timestamp-based ID) 267 267 // --------------------------------------------------------------------------- 268 268 269 - const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz"; 269 + const TID_CHARS = "234567abcdefghijklmnopqrstuvwxyz" 270 270 271 271 function generateTid(): string { 272 - const now = BigInt(Date.now()) * 1000n; 273 - const clockId = BigInt(Math.floor(Math.random() * 1024)); 274 - const tid = (now << 10n) | clockId; 272 + const now = BigInt(Date.now()) * 1000n 273 + // eslint-disable-next-line sonarjs/pseudo-random -- not security-sensitive; clock ID is only for TID collision avoidance 274 + const clockId = BigInt(Math.floor(Math.random() * 1024)) 275 + const tid = (now << 10n) | clockId 275 276 276 - let result = ""; 277 - let remaining = tid; 277 + // eslint-disable-next-line functional/no-let -- bit-manipulation algorithm for base32 encoding 278 + let result = "" 279 + // eslint-disable-next-line functional/no-let 280 + let remaining = tid 281 + // eslint-disable-next-line functional/no-loop-statements, functional/no-let 278 282 for (let i = 0; i < 13; i++) { 279 - result = TID_CHARS[Number(remaining & 31n)] + result; 280 - remaining >>= 5n; 283 + result = TID_CHARS[Number(remaining & 31n)] + result 284 + remaining >>= 5n 281 285 } 282 286 283 - return result; 287 + return result 284 288 }
+27 -27
web/src/lib/storage-types.ts
··· 1 1 // TypeScript equivalents of opake-core storage types. 2 2 // Mirrors: crates/opake-core/src/storage.rs 3 3 4 - import type { DpopKeyPair } from "./crypto-types"; 4 + import type { DpopKeyPair } from "./crypto-types" 5 5 6 6 export interface Config { 7 - defaultDid: string | null; 8 - accounts: Record<string, AccountConfig>; 9 - appviewUrl: string | null; 7 + readonly defaultDid: string | null 8 + readonly accounts: Readonly<Record<string, AccountConfig>> 9 + readonly appviewUrl: string | null 10 10 } 11 11 12 12 export interface AccountConfig { 13 - pdsUrl: string; 14 - handle: string; 13 + readonly pdsUrl: string 14 + readonly handle: string 15 15 } 16 16 17 17 export interface Identity { 18 - did: string; 19 - public_key: string; // base64 X25519 20 - private_key: string; // base64 X25519 21 - signing_key: string | null; // base64 Ed25519 22 - verify_key: string | null; // base64 Ed25519 18 + readonly did: string 19 + readonly public_key: string // base64 X25519 20 + readonly private_key: string // base64 X25519 21 + readonly signing_key: string | null // base64 Ed25519 22 + readonly verify_key: string | null // base64 Ed25519 23 23 } 24 24 25 25 // Mirrors: opake-core Session enum (client/xrpc/mod.rs) 26 26 // Discriminated union — the `type` tag matches Rust's #[serde(tag = "type")] 27 27 28 28 export interface LegacySession { 29 - type: "legacy"; 30 - did: string; 31 - handle: string; 32 - accessJwt: string; 33 - refreshJwt: string; 29 + readonly type: "legacy" 30 + readonly did: string 31 + readonly handle: string 32 + readonly accessJwt: string 33 + readonly refreshJwt: string 34 34 } 35 35 36 36 export interface OAuthSession { 37 - type: "oauth"; 38 - did: string; 39 - handle: string; 40 - accessToken: string; 41 - refreshToken: string; 42 - dpopKey: DpopKeyPair; 43 - tokenEndpoint: string; 44 - dpopNonce: string | null; 45 - expiresAt: number | null; 46 - clientId: string; 37 + readonly type: "oauth" 38 + readonly did: string 39 + readonly handle: string 40 + accessToken: string 41 + refreshToken: string 42 + readonly dpopKey: DpopKeyPair 43 + readonly tokenEndpoint: string 44 + dpopNonce: string | null 45 + expiresAt: number | null 46 + readonly clientId: string 47 47 } 48 48 49 - export type Session = LegacySession | OAuthSession; 49 + export type Session = LegacySession | OAuthSession
+11 -11
web/src/lib/storage.ts
··· 1 1 // Platform-agnostic storage contract. 2 2 // Mirrors: crates/opake-core/src/storage.rs — Storage trait 3 3 4 - import type { Config, Identity, Session } from "./storage-types"; 4 + import type { Config, Identity, Session } from "./storage-types" 5 5 6 6 export interface Storage { 7 - loadConfig(): Promise<Config>; 8 - saveConfig(config: Config): Promise<void>; 9 - loadIdentity(did: string): Promise<Identity>; 10 - saveIdentity(did: string, identity: Identity): Promise<void>; 11 - loadSession(did: string): Promise<Session>; 12 - saveSession(did: string, session: Session): Promise<void>; 13 - removeAccount(did: string): Promise<void>; 7 + loadConfig(): Promise<Config> 8 + saveConfig(config: Config): Promise<void> 9 + loadIdentity(did: string): Promise<Identity> 10 + saveIdentity(did: string, identity: Identity): Promise<void> 11 + loadSession(did: string): Promise<Session> 12 + saveSession(did: string, session: Session): Promise<void> 13 + removeAccount(did: string): Promise<void> 14 14 } 15 15 16 16 export class StorageError extends Error { 17 17 constructor(message: string) { 18 - super(message); 19 - this.name = "StorageError"; 18 + super(message) 19 + this.name = "StorageError" 20 20 } 21 21 } 22 22 23 23 /** `did:plc:abc` → `did_plc_abc` — mirrors `sanitize_did` in opake-core. */ 24 24 export function sanitizeDid(did: string): string { 25 - return did.replaceAll(":", "_"); 25 + return did.replaceAll(":", "_") 26 26 }
+20 -14
web/src/lib/worker.ts
··· 1 1 // Shared crypto worker singleton. 2 2 // One Comlink-wrapped WASM worker for the entire app. 3 3 4 - import { wrap, type Remote } from "comlink"; 5 - import type { CryptoApi } from "@/workers/crypto.worker"; 4 + import { wrap, type Remote } from "comlink" 5 + import type { CryptoApi } from "@/workers/crypto.worker" 6 6 7 - let instance: Remote<CryptoApi> | null = null; 7 + function createWorker(): Remote<CryptoApi> { 8 + const raw = new Worker(new URL("../workers/crypto.worker.ts", import.meta.url), { 9 + type: "module", 10 + }) 11 + raw.addEventListener("error", (e) => { 12 + console.error("[worker] error:", e.message, e.filename, e.lineno) 13 + }) 14 + return wrap<CryptoApi>(raw) 15 + } 8 16 9 - export function getCryptoWorker(): Remote<CryptoApi> { 10 - if (!instance) { 11 - const raw = new Worker( 12 - new URL("../workers/crypto.worker.ts", import.meta.url), 13 - { type: "module" }, 14 - ); 15 - raw.addEventListener("error", (e) => { 16 - console.error("[worker] error:", e.message, e.filename, e.lineno); 17 - }); 18 - instance = wrap<CryptoApi>(raw); 17 + const memo = /* @__PURE__ */ (() => { 18 + const ref = { current: null as Remote<CryptoApi> | null } 19 + return () => { 20 + ref.current ??= createWorker() 21 + return ref.current 19 22 } 20 - return instance; 23 + })() 24 + 25 + export function getCryptoWorker(): Remote<CryptoApi> { 26 + return memo() 21 27 }
+19 -12
web/src/main.tsx
··· 1 - import { StrictMode } from "react"; 2 - import { createRoot } from "react-dom/client"; 3 - import { createRouter, RouterProvider } from "@tanstack/react-router"; 4 - import { routeTree } from "./routeTree.gen"; 5 - import "./index.css"; 6 - import { getCryptoWorker } from "@/lib/worker"; 1 + import { enableMapSet, enableArrayMethods } from "immer" 2 + import { StrictMode } from "react" 3 + import { createRoot } from "react-dom/client" 4 + import { createRouter, RouterProvider } from "@tanstack/react-router" 5 + import { routeTree } from "./routeTree.gen" 6 + import "./index.css" 7 + import { getCryptoWorker } from "@/lib/worker" 8 + 9 + enableMapSet() 10 + enableArrayMethods() 7 11 8 - console.debug("[opake] app starting"); 9 - getCryptoWorker(); // warm up WASM worker early 12 + console.debug("[opake] app starting") 13 + getCryptoWorker() // warm up WASM worker early 10 14 11 - const router = createRouter({ routeTree }); 15 + const router = createRouter({ routeTree }) 12 16 13 17 declare module "@tanstack/react-router" { 14 18 interface Register { 15 - router: typeof router; 19 + router: typeof router 16 20 } 17 21 } 18 22 19 - createRoot(document.getElementById("root")!).render( 23 + const rootElement = document.getElementById("root") 24 + if (!rootElement) throw new Error("Missing #root element") 25 + 26 + createRoot(rootElement).render( 20 27 <StrictMode> 21 28 <RouterProvider router={router} /> 22 29 </StrictMode>, 23 - ); 30 + )
+16 -16
web/src/routes/__root.tsx
··· 1 - import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router"; 2 - import { useAuthStore } from "@/stores/auth"; 1 + import { createRootRoute, Outlet, useRouter } from "@tanstack/react-router" 2 + import { useAuthStore } from "@/stores/auth" 3 3 4 4 function RootLayout() { 5 - return <Outlet />; 5 + return <Outlet /> 6 6 } 7 7 8 - function RootError({ error }: { error: Error }) { 9 - const router = useRouter(); 8 + function RootError({ error }: Readonly<{ error: Error }>) { 9 + const router = useRouter() 10 10 11 11 return ( 12 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 13 - <div className="card card-bordered max-w-md bg-base-100 p-8 text-center"> 14 - <h1 className="mb-2 text-lg font-medium text-error"> 15 - Something went wrong 16 - </h1> 17 - <p className="mb-6 text-sm text-text-muted">{error.message}</p> 12 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 13 + <div className="card card-bordered bg-base-100 max-w-md p-8 text-center"> 14 + <h1 className="text-error mb-2 text-lg font-medium">Something went wrong</h1> 15 + <p className="text-text-muted mb-6 text-sm">{error.message}</p> 18 16 <button 19 - onClick={() => router.invalidate()} 17 + onClick={() => { 18 + void router.invalidate() 19 + }} 20 20 className="btn btn-neutral btn-sm" 21 21 > 22 22 Try again 23 23 </button> 24 24 </div> 25 25 </div> 26 - ); 26 + ) 27 27 } 28 28 29 29 export const Route = createRootRoute({ 30 30 beforeLoad: async () => { 31 - const state = useAuthStore.getState(); 31 + const state = useAuthStore.getState() 32 32 if (state.phase === "initializing") { 33 - await state.boot(); 33 + await state.boot() 34 34 } 35 35 }, 36 36 component: RootLayout, 37 37 errorComponent: RootError, 38 - }); 38 + })
+36 -52
web/src/routes/cabinet.devices.index.tsx
··· 1 - import { createFileRoute, redirect, Link } from "@tanstack/react-router"; 2 - import { OpakeLogo } from "@/components/OpakeLogo"; 3 - import { useAuthStore } from "@/stores/auth"; 4 - import { ArrowsLeftRight, Key } from "@phosphor-icons/react"; 1 + import { createFileRoute, redirect, Link } from "@tanstack/react-router" 2 + import { OpakeLogo } from "@/components/OpakeLogo" 3 + import { useAuthStore } from "@/stores/auth" 4 + import { ArrowsLeftRightIcon, KeyIcon } from "@phosphor-icons/react" 5 5 6 6 function DevicesPage() { 7 - const phase = useAuthStore((s) => s.phase); 7 + const phase = useAuthStore((s) => s.phase) 8 8 9 9 if (phase === "ready") { 10 - return <ActiveIdentityView />; 10 + return <ActiveIdentityView /> 11 11 } 12 12 13 - return <IdentityRequiredView />; 13 + return <IdentityRequiredView /> 14 14 } 15 15 16 - /** User already has identity on this device — show device management. */ 16 + /** UserIcon already has identity on this device — show device management. */ 17 17 function ActiveIdentityView() { 18 18 return ( 19 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 19 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 20 20 <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 21 21 <OpakeLogo size="lg" /> 22 22 23 23 <div className="text-center"> 24 - <h1 className="text-2xl font-semibold text-base-content"> 25 - Device identity active 26 - </h1> 27 - <p className="mt-2 text-sm text-base-content/60"> 28 - This device has an encryption identity. You can approve pairing 29 - requests from other devices. 24 + <h1 className="text-base-content text-2xl font-semibold">Device identity active</h1> 25 + <p className="text-base-content/60 mt-2 text-sm"> 26 + This device has an encryption identity. You can approve pairing requests from other 27 + devices. 30 28 </p> 31 29 </div> 32 30 33 - <Link 34 - to="/cabinet/devices/pair" 35 - className="btn btn-neutral w-full max-w-xs" 36 - > 37 - <ArrowsLeftRight size={20} aria-hidden="true" /> 31 + <Link to="/cabinet/devices/pair" className="btn btn-neutral w-full max-w-xs"> 32 + <ArrowsLeftRightIcon size={20} aria-hidden="true" /> 38 33 Manage pairing 39 34 </Link> 40 35 41 - <Link to="/cabinet" className="text-sm text-base-content/50 hover:text-base-content/70"> 36 + <Link to="/cabinet" className="text-base-content/50 hover:text-base-content/70 text-sm"> 42 37 Back to cabinet 43 38 </Link> 44 39 </div> 45 40 </div> 46 - ); 41 + ) 47 42 } 48 43 49 - /** User needs to get their identity onto this device. */ 44 + /** UserIcon needs to get their identity onto this device. */ 50 45 function IdentityRequiredView() { 51 46 return ( 52 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 47 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 53 48 <div className="flex w-full max-w-lg flex-col items-center gap-8 px-6 py-12"> 54 49 <OpakeLogo size="lg" /> 55 50 56 51 <div className="text-center"> 57 - <h1 className="text-2xl font-semibold text-base-content"> 58 - Set up encryption 59 - </h1> 60 - <p className="mt-2 text-sm text-base-content/60"> 61 - Your account has an encryption identity on another device. 62 - Transfer it to use Opake here. 52 + <h1 className="text-base-content text-2xl font-semibold">Set up encryption</h1> 53 + <p className="text-base-content/60 mt-2 text-sm"> 54 + Your account has an encryption identity on another device. Transfer it to use Opake 55 + here. 63 56 </p> 64 57 </div> 65 58 66 59 <div className="grid w-full max-w-md gap-4 sm:grid-cols-2"> 67 60 <Link 68 61 to="/cabinet/devices/pair" 69 - className="card card-bordered bg-base-100 p-5 transition-colors hover:border-primary/40" 62 + className="card card-bordered bg-base-100 hover:border-primary/40 p-5 transition-colors" 70 63 > 71 - <ArrowsLeftRight size={24} className="mb-3 text-primary" aria-hidden="true" /> 72 - <h2 className="font-medium text-base-content"> 73 - Pair with existing device 74 - </h2> 75 - <p className="mt-1 text-caption text-base-content/60"> 64 + <ArrowsLeftRightIcon size={24} className="text-primary mb-3" aria-hidden="true" /> 65 + <h2 className="text-base-content font-medium">Pair with existing device</h2> 66 + <p className="text-caption text-base-content/60 mt-1"> 76 67 Transfer your encryption identity from another device. 77 68 </p> 78 69 </Link> 79 70 80 - <div 81 - className="card card-bordered bg-base-100 p-5 opacity-50" 82 - aria-disabled="true" 83 - > 84 - <Key size={24} className="mb-3 text-base-content/40" aria-hidden="true" /> 85 - <h2 className="font-medium text-base-content"> 86 - Recover from seed phrase 87 - </h2> 88 - <p className="mt-1 text-caption text-base-content/60"> 71 + <div className="card card-bordered bg-base-100 p-5 opacity-50" aria-disabled="true"> 72 + <KeyIcon size={24} className="text-base-content/40 mb-3" aria-hidden="true" /> 73 + <h2 className="text-base-content font-medium">Recover from seed phrase</h2> 74 + <p className="text-caption text-base-content/60 mt-1"> 89 75 Restore your identity from your backup phrase. 90 76 </p> 91 - <span className="mt-2 inline-block text-xs text-base-content/40"> 92 - Coming soon 93 - </span> 77 + <span className="text-base-content/40 mt-2 inline-block text-xs">Coming soon</span> 94 78 </div> 95 79 </div> 96 80 </div> 97 81 </div> 98 - ); 82 + ) 99 83 } 100 84 101 85 export const Route = createFileRoute("/cabinet/devices/")({ 102 86 beforeLoad: () => { 103 - const state = useAuthStore.getState(); 87 + const state = useAuthStore.getState() 104 88 if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 105 - throw redirect({ to: "/login" }); 89 + throw redirect({ to: "/login" }) 106 90 } 107 91 }, 108 92 component: DevicesPage, 109 - }); 93 + })
+156 -152
web/src/routes/cabinet.devices.pair.tsx
··· 1 - import { useCallback, useEffect, useRef, useState } from "react"; 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 - import { OpakeLogo } from "@/components/OpakeLogo"; 4 - import { useAuthStore } from "@/stores/auth"; 5 - import { getCryptoWorker } from "@/lib/worker"; 6 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 7 - import { formatFingerprint } from "@/lib/encoding"; 8 - import { rkeyFromUri } from "@/lib/encoding"; 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "react" 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 + import { OpakeLogo } from "@/components/OpakeLogo" 4 + import { useAuthStore } from "@/stores/auth" 5 + import { getCryptoWorker } from "@/lib/worker" 6 + import { IndexedDbStorage } from "@/lib/indexeddb-storage" 7 + import { formatFingerprint } from "@/lib/encoding" 8 + import { rkeyFromUri } from "@/lib/encoding" 9 9 import { 10 10 createPairRequest, 11 11 listPairRequests, ··· 14 14 approvePairRequest, 15 15 cleanupPairRecords, 16 16 type PendingPairRequest, 17 - } from "@/lib/pairing"; 18 - import { CheckCircle, Warning } from "@phosphor-icons/react"; 17 + } from "@/lib/pairing" 18 + import { CheckCircleIcon, WarningIcon } from "@phosphor-icons/react" 19 19 20 - const POLL_INTERVAL_MS = 3000; 20 + const POLL_INTERVAL_MS = 3000 21 21 22 - const storage = new IndexedDbStorage(); 22 + const storage = new IndexedDbStorage() 23 23 24 24 // --------------------------------------------------------------------------- 25 25 // Route ··· 27 27 28 28 export const Route = createFileRoute("/cabinet/devices/pair")({ 29 29 beforeLoad: () => { 30 - const state = useAuthStore.getState(); 30 + const state = useAuthStore.getState() 31 31 if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 32 - throw redirect({ to: "/login" }); 32 + throw redirect({ to: "/login" }) 33 33 } 34 34 }, 35 35 component: PairPage, 36 - }); 36 + }) 37 37 38 38 // --------------------------------------------------------------------------- 39 39 // Page component — dispatches to request or approve mode 40 40 // --------------------------------------------------------------------------- 41 41 42 42 function PairPage() { 43 - const phase = useAuthStore((s) => s.phase); 44 - const [mode, setMode] = useState<"loading" | "request" | "approve">("loading"); 43 + const phase = useAuthStore((s) => s.phase) 44 + 45 + // Synchronously derive initial mode: "awaiting_identity" always means request, 46 + // "ready" needs an async identity check so starts as "loading". 47 + const initialMode = useMemo<"loading" | "request" | "approve">( 48 + () => (phase === "awaiting_identity" ? "request" : "loading"), 49 + [phase], 50 + ) 51 + const [mode, setMode] = useState(initialMode) 45 52 46 53 useEffect(() => { 47 - if (phase === "awaiting_identity") { 48 - setMode("request"); 49 - return; 50 - } 54 + // Only need the async probe when phase is "ready" 55 + if (phase !== "ready") return 51 56 52 - // phase === "ready" — check if we have a local identity 53 - const state = useAuthStore.getState(); 54 - if (state.phase !== "ready") return; 57 + const state = useAuthStore.getState() 58 + if (state.phase !== "ready") return 55 59 56 60 storage 57 61 .loadIdentity(state.did) 58 62 .then(() => setMode("approve")) 59 - .catch(() => setMode("request")); 60 - }, [phase]); 63 + .catch(() => setMode("request")) 64 + }, [phase]) 61 65 62 66 if (mode === "loading") { 63 67 return ( 64 68 <PageShell> 65 69 <span className="loading loading-spinner loading-lg text-primary" /> 66 70 </PageShell> 67 - ); 71 + ) 68 72 } 69 73 70 74 if (mode === "request") { 71 - return <RequestMode />; 75 + return <RequestMode /> 72 76 } 73 77 74 - return <ApproveMode />; 78 + return <ApproveMode /> 75 79 } 76 80 77 81 // --------------------------------------------------------------------------- ··· 83 87 | { step: "waiting"; fingerprint: string; requestUri: string } 84 88 | { step: "receiving" } 85 89 | { step: "success" } 86 - | { step: "error"; message: string }; 90 + | { step: "error"; message: string } 87 91 88 92 function RequestMode() { 89 - const navigate = useNavigate(); 90 - const [state, setState] = useState<RequestState>({ step: "generating" }); 91 - const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null); 92 - const pollRef = useRef<ReturnType<typeof setInterval> | null>(null); 93 + const navigate = useNavigate() 94 + const [state, setState] = useState<RequestState>({ step: "generating" }) 95 + const ephemeralPrivKeyRef = useRef<Uint8Array | null>(null) 96 + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) 93 97 94 98 const cleanup = useCallback(() => { 95 99 if (pollRef.current) { 96 - clearInterval(pollRef.current); 97 - pollRef.current = null; 100 + clearInterval(pollRef.current) 101 + pollRef.current = null 98 102 } 99 - }, []); 103 + }, []) 100 104 101 105 useEffect(() => { 102 - let cancelled = false; 106 + const cancelledRef = { current: false } 103 107 104 108 async function init() { 105 - const authState = useAuthStore.getState(); 106 - if (authState.phase !== "awaiting_identity" && authState.phase !== "ready") return; 109 + const authState = useAuthStore.getState() 110 + if (authState.phase !== "awaiting_identity" && authState.phase !== "ready") return 107 111 108 - const { did, pdsUrl } = authState; 109 - const worker = getCryptoWorker(); 110 - const session = await storage.loadSession(did); 112 + const { did, pdsUrl } = authState 113 + const worker = getCryptoWorker() 114 + const session = await storage.loadSession(did) 111 115 112 116 try { 113 117 // Generate ephemeral keypair 114 - const ephemeral = await worker.generateEphemeralKeypair(); 115 - ephemeralPrivKeyRef.current = ephemeral.privateKey; 118 + const ephemeral = await worker.generateEphemeralKeypair() 119 + ephemeralPrivKeyRef.current = ephemeral.privateKey 116 120 117 121 // Create pair request on PDS 118 - const requestUri = await createPairRequest(pdsUrl, did, ephemeral.publicKey, session); 122 + const requestUri = await createPairRequest(pdsUrl, did, ephemeral.publicKey, session) 119 123 120 - if (cancelled) return; 124 + if (cancelledRef.current) return 121 125 122 - const fingerprint = formatFingerprint(ephemeral.publicKey); 123 - const requestRkey = rkeyFromUri(requestUri); 124 - setState({ step: "waiting", fingerprint, requestUri }); 126 + const fingerprint = formatFingerprint(ephemeral.publicKey) 127 + const requestRkey = rkeyFromUri(requestUri) 128 + setState({ step: "waiting", fingerprint, requestUri }) 125 129 126 130 // Poll for response 127 131 pollRef.current = setInterval(async () => { 128 132 try { 129 - const response = await pollForPairResponse(pdsUrl, did, requestRkey, session); 130 - if (!response || cancelled) return; 133 + const response = await pollForPairResponse(pdsUrl, did, requestRkey, session) 134 + if (!response || cancelledRef.current) return 135 + 136 + cleanup() 137 + setState({ step: "receiving" }) 131 138 132 - cleanup(); 133 - setState({ step: "receiving" }); 139 + const privKey = ephemeralPrivKeyRef.current 140 + if (!privKey) { 141 + setState({ step: "error", message: "Ephemeral private key unavailable" }) 142 + return 143 + } 134 144 135 - const identity = await receivePairResponse( 136 - response, 137 - ephemeralPrivKeyRef.current!, 138 - worker, 139 - ); 145 + const identity = await receivePairResponse(response, privKey, worker) 140 146 141 - await storage.saveIdentity(did, identity); 147 + await storage.saveIdentity(did, identity) 142 148 143 149 // Clean up PDS records (best-effort) 144 - await cleanupPairRecords(pdsUrl, did, requestUri, null, session).catch(() => {}); 150 + await cleanupPairRecords(pdsUrl, did, requestUri, null, session).catch( 151 + Function.prototype as () => void, 152 + ) 145 153 146 154 // Transition auth store to ready 147 155 useAuthStore.setState({ ··· 149 157 did, 150 158 handle: authState.handle, 151 159 pdsUrl, 152 - }); 160 + }) 153 161 154 - setState({ step: "success" }); 155 - setTimeout(() => navigate({ to: "/cabinet" }), 1500); 162 + setState({ step: "success" }) 163 + setTimeout(() => navigate({ to: "/cabinet" }), 1500) 156 164 } catch (err) { 157 - cleanup(); 165 + cleanup() 158 166 setState({ 159 167 step: "error", 160 168 message: err instanceof Error ? err.message : String(err), 161 - }); 169 + }) 162 170 } 163 - }, POLL_INTERVAL_MS); 171 + }, POLL_INTERVAL_MS) 164 172 } catch (err) { 165 - if (cancelled) return; 173 + if (cancelledRef.current) return 166 174 setState({ 167 175 step: "error", 168 176 message: err instanceof Error ? err.message : String(err), 169 - }); 177 + }) 170 178 } 171 179 } 172 180 173 - init(); 181 + void init() 174 182 return () => { 175 - cancelled = true; 176 - cleanup(); 177 - }; 178 - }, [cleanup, navigate]); 183 + cancelledRef.current = true 184 + cleanup() 185 + } 186 + }, [cleanup, navigate]) 179 187 180 188 return ( 181 189 <PageShell> 182 190 {state.step === "generating" && ( 183 191 <div className="flex flex-col items-center gap-4"> 184 192 <span className="loading loading-spinner loading-lg text-primary" /> 185 - <p className="text-sm text-base-content/60">Generating keypair…</p> 193 + <p className="text-base-content/60 text-sm">Generating keypair…</p> 186 194 </div> 187 195 )} 188 196 189 197 {state.step === "waiting" && ( 190 198 <div className="flex flex-col items-center gap-6 text-center"> 191 - <h1 className="text-2xl font-semibold text-base-content"> 192 - Pair this device 193 - </h1> 194 - <p className="text-sm text-base-content/60"> 199 + <h1 className="text-base-content text-2xl font-semibold">Pair this device</h1> 200 + <p className="text-base-content/60 text-sm"> 195 201 Approve this request from your existing device. Verify the fingerprint matches. 196 202 </p> 197 203 198 - <div className="rounded-lg bg-base-100 px-6 py-4 font-mono text-lg tracking-wider text-primary"> 204 + <div className="bg-base-100 text-primary rounded-lg px-6 py-4 font-mono text-lg tracking-wider"> 199 205 {state.fingerprint} 200 206 </div> 201 207 202 - <div className="flex items-center gap-2 text-sm text-base-content/50"> 208 + <div className="text-base-content/50 flex items-center gap-2 text-sm"> 203 209 <span className="loading loading-spinner loading-xs" /> 204 210 Waiting for approval… 205 211 </div> ··· 209 215 {state.step === "receiving" && ( 210 216 <div className="flex flex-col items-center gap-4"> 211 217 <span className="loading loading-spinner loading-lg text-primary" /> 212 - <p className="text-sm text-base-content/60">Receiving identity…</p> 218 + <p className="text-base-content/60 text-sm">Receiving identity…</p> 213 219 </div> 214 220 )} 215 221 216 222 {state.step === "success" && ( 217 223 <div className="flex flex-col items-center gap-4"> 218 - <CheckCircle size={48} className="text-success" weight="fill" /> 219 - <p className="text-lg font-medium text-base-content">Device paired</p> 220 - <p className="text-sm text-base-content/60">Redirecting to cabinet…</p> 224 + <CheckCircleIcon size={48} className="text-success" weight="fill" /> 225 + <p className="text-base-content text-lg font-medium">Device paired</p> 226 + <p className="text-base-content/60 text-sm">Redirecting to cabinet…</p> 221 227 </div> 222 228 )} 223 229 224 230 {state.step === "error" && <ErrorView message={state.message} />} 225 231 </PageShell> 226 - ); 232 + ) 227 233 } 228 234 229 235 // --------------------------------------------------------------------------- ··· 236 242 | { step: "selecting"; requests: PendingPairRequest[] } 237 243 | { step: "approving" } 238 244 | { step: "success" } 239 - | { step: "error"; message: string }; 245 + | { step: "error"; message: string } 240 246 241 - function ApproveMode() { 242 - const [state, setState] = useState<ApproveState>({ step: "loading" }); 247 + async function fetchPairRequests(): Promise<ApproveState> { 248 + const authState = useAuthStore.getState() 249 + if (authState.phase !== "ready") return { step: "loading" } 243 250 244 - const loadRequests = useCallback(async () => { 245 - const authState = useAuthStore.getState(); 246 - if (authState.phase !== "ready") return; 251 + const { did, pdsUrl } = authState 252 + const session = await storage.loadSession(did) 247 253 248 - const { did, pdsUrl } = authState; 249 - const session = await storage.loadSession(did); 250 - 251 - try { 252 - const requests = await listPairRequests(pdsUrl, did, session); 253 - if (requests.length === 0) { 254 - setState({ step: "empty" }); 255 - } else { 256 - setState({ step: "selecting", requests }); 257 - } 258 - } catch (err) { 259 - setState({ 260 - step: "error", 261 - message: err instanceof Error ? err.message : String(err), 262 - }); 254 + try { 255 + const requests = await listPairRequests(pdsUrl, did, session) 256 + return requests.length === 0 ? { step: "empty" } : { step: "selecting", requests } 257 + } catch (err) { 258 + return { 259 + step: "error", 260 + message: err instanceof Error ? err.message : String(err), 263 261 } 264 - }, []); 262 + } 263 + } 264 + 265 + function ApproveMode() { 266 + const [state, setState] = useState<ApproveState>({ step: "loading" }) 267 + const initialLoadDone = useRef(false) 265 268 266 269 useEffect(() => { 267 - loadRequests(); 268 - }, [loadRequests]); 270 + if (initialLoadDone.current) return 271 + initialLoadDone.current = true 272 + 273 + void fetchPairRequests().then(setState) 274 + }, []) 275 + 276 + const handleRefresh = useCallback(() => { 277 + setState({ step: "loading" }) 278 + void fetchPairRequests().then(setState) 279 + }, []) 269 280 270 281 const handleApprove = useCallback(async (request: PendingPairRequest) => { 271 - setState({ step: "approving" }); 282 + setState({ step: "approving" }) 272 283 273 - const authState = useAuthStore.getState(); 274 - if (authState.phase !== "ready") return; 284 + const authState = useAuthStore.getState() 285 + if (authState.phase !== "ready") return 275 286 276 - const { did, pdsUrl } = authState; 277 - const worker = getCryptoWorker(); 287 + const { did, pdsUrl } = authState 288 + const worker = getCryptoWorker() 278 289 279 290 try { 280 - const session = await storage.loadSession(did); 281 - const identity = await storage.loadIdentity(did); 291 + const session = await storage.loadSession(did) 292 + const identity = await storage.loadIdentity(did) 282 293 283 294 await approvePairRequest( 284 295 pdsUrl, ··· 288 299 identity, 289 300 session, 290 301 worker, 291 - ); 302 + ) 292 303 293 - setState({ step: "success" }); 304 + setState({ step: "success" }) 294 305 } catch (err) { 295 306 setState({ 296 307 step: "error", 297 308 message: err instanceof Error ? err.message : String(err), 298 - }); 309 + }) 299 310 } 300 - }, []); 311 + }, []) 301 312 302 313 return ( 303 314 <PageShell> 304 315 {state.step === "loading" && ( 305 316 <div className="flex flex-col items-center gap-4"> 306 317 <span className="loading loading-spinner loading-lg text-primary" /> 307 - <p className="text-sm text-base-content/60">Loading pair requests…</p> 318 + <p className="text-base-content/60 text-sm">Loading pair requests…</p> 308 319 </div> 309 320 )} 310 321 311 322 {state.step === "empty" && ( 312 323 <div className="flex flex-col items-center gap-6 text-center"> 313 - <h1 className="text-2xl font-semibold text-base-content"> 314 - No pending requests 315 - </h1> 316 - <p className="text-sm text-base-content/60"> 324 + <h1 className="text-base-content text-2xl font-semibold">No pending requests</h1> 325 + <p className="text-base-content/60 text-sm"> 317 326 Start a pairing request from your new device first, then come back here to approve it. 318 327 </p> 319 - <button onClick={loadRequests} className="btn btn-neutral btn-sm"> 328 + <button onClick={handleRefresh} className="btn btn-neutral btn-sm"> 320 329 Refresh 321 330 </button> 322 331 </div> ··· 324 333 325 334 {state.step === "selecting" && ( 326 335 <div className="flex flex-col items-center gap-6"> 327 - <h1 className="text-2xl font-semibold text-base-content"> 328 - Approve a device 329 - </h1> 330 - <p className="text-sm text-base-content/60"> 336 + <h1 className="text-base-content text-2xl font-semibold">Approve a device</h1> 337 + <p className="text-base-content/60 text-sm"> 331 338 Verify the fingerprint matches what your new device shows. 332 339 </p> 333 340 334 341 <div className="flex w-full max-w-sm flex-col gap-3"> 335 342 {state.requests.map((req) => ( 336 - <div 337 - key={req.uri} 338 - className="card card-bordered bg-base-100 p-4" 339 - > 340 - <div className="mb-2 font-mono text-sm tracking-wider text-primary"> 343 + <div key={req.uri} className="card card-bordered bg-base-100 p-4"> 344 + <div className="text-primary mb-2 font-mono text-sm tracking-wider"> 341 345 {req.fingerprint} 342 346 </div> 343 - <div className="mb-3 text-xs text-base-content/50"> 347 + <div className="text-base-content/50 mb-3 text-xs"> 344 348 {new Date(req.createdAt).toLocaleString()} 345 349 </div> 346 350 <button 347 - onClick={() => handleApprove(req)} 351 + onClick={() => { 352 + void handleApprove(req) 353 + }} 348 354 className="btn btn-neutral btn-sm w-full" 349 355 > 350 356 Approve ··· 358 364 {state.step === "approving" && ( 359 365 <div className="flex flex-col items-center gap-4"> 360 366 <span className="loading loading-spinner loading-lg text-primary" /> 361 - <p className="text-sm text-base-content/60">Encrypting and sending identity…</p> 367 + <p className="text-base-content/60 text-sm">Encrypting and sending identity…</p> 362 368 </div> 363 369 )} 364 370 365 371 {state.step === "success" && ( 366 372 <div className="flex flex-col items-center gap-4"> 367 - <CheckCircle size={48} className="text-success" weight="fill" /> 368 - <p className="text-lg font-medium text-base-content">Approved</p> 369 - <p className="text-sm text-base-content/60"> 373 + <CheckCircleIcon size={48} className="text-success" weight="fill" /> 374 + <p className="text-base-content text-lg font-medium">Approved</p> 375 + <p className="text-base-content/60 text-sm"> 370 376 The other device should receive your identity shortly. 371 377 </p> 372 378 </div> ··· 374 380 375 381 {state.step === "error" && <ErrorView message={state.message} />} 376 382 </PageShell> 377 - ); 383 + ) 378 384 } 379 385 380 386 // --------------------------------------------------------------------------- 381 387 // Shared components 382 388 // --------------------------------------------------------------------------- 383 389 384 - function PageShell({ children }: { children: React.ReactNode }) { 390 + function PageShell({ children }: Readonly<{ children: React.ReactNode }>) { 385 391 return ( 386 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 392 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 387 393 <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 388 394 <OpakeLogo size="lg" /> 389 395 {children} 390 396 </div> 391 397 </div> 392 - ); 398 + ) 393 399 } 394 400 395 - function ErrorView({ message }: { message: string }) { 401 + function ErrorView({ message }: Readonly<{ message: string }>) { 396 402 return ( 397 403 <div className="flex flex-col items-center gap-6 text-center"> 398 - <Warning size={48} className="text-error" weight="fill" /> 404 + <WarningIcon size={48} className="text-error" weight="fill" /> 399 405 <div className="flex flex-col gap-2"> 400 - <h1 className="text-2xl font-semibold text-base-content"> 401 - Pairing failed 402 - </h1> 406 + <h1 className="text-base-content text-2xl font-semibold">Pairing failed</h1> 403 407 <p className="text-base-content/60">{message}</p> 404 408 </div> 405 409 <a href="/cabinet/devices" className="btn btn-neutral btn-sm"> 406 410 Try again 407 411 </a> 408 412 </div> 409 - ); 413 + ) 410 414 }
+34 -42
web/src/routes/cabinet.tsx
··· 1 - import { useState } from "react"; 2 - import { createFileRoute, redirect, Outlet, useMatch } from "@tanstack/react-router"; 3 - import { Sidebar } from "@/components/cabinet/Sidebar"; 4 - import { TopBar } from "@/components/cabinet/TopBar"; 5 - import { PanelStack } from "@/components/cabinet/PanelStack"; 6 - import { useAuthStore } from "@/stores/auth"; 7 - import type { 8 - FileItem, 9 - Panel, 10 - SectionType, 11 - } from "@/components/cabinet/types"; 1 + import { useState } from "react" 2 + import { createFileRoute, redirect, Outlet, useMatch } from "@tanstack/react-router" 3 + import { Sidebar } from "@/components/cabinet/Sidebar" 4 + import { TopBar } from "@/components/cabinet/TopBar" 5 + import { PanelStack } from "@/components/cabinet/PanelStack" 6 + import { useAuthStore } from "@/stores/auth" 7 + import type { FileItem, Panel, SectionType } from "@/components/cabinet/types" 12 8 13 9 function CabinetLayout() { 14 10 // If a child route matched (e.g. /cabinet/devices), render it instead of the cabinet UI 15 - const devicesMatch = useMatch({ from: "/cabinet/devices/", shouldThrow: false }); 16 - const pairMatch = useMatch({ from: "/cabinet/devices/pair", shouldThrow: false }); 11 + const devicesMatch = useMatch({ from: "/cabinet/devices/", shouldThrow: false }) 12 + const pairMatch = useMatch({ from: "/cabinet/devices/pair", shouldThrow: false }) 17 13 18 14 if (devicesMatch || pairMatch) { 19 - return <Outlet />; 15 + return <Outlet /> 20 16 } 21 17 22 - return <CabinetPage />; 18 + return <CabinetPage /> 23 19 } 24 20 25 21 function CabinetPage() { 26 - const [panels, setPanels] = useState<Panel[]>([ 27 - { type: "root", title: "The Cabinet" }, 28 - ]); 29 - const [viewMode, setViewMode] = useState<"list" | "grid">("list"); 30 - const [searchQuery, setSearchQuery] = useState(""); 22 + const [panels, setPanels] = useState<Panel[]>([{ type: "root", title: "The Cabinet" }]) 23 + const [viewMode, setViewMode] = useState<"list" | "grid">("list") 24 + const [searchQuery, setSearchQuery] = useState("") 31 25 const [starredIds, setStarredIds] = useState( 32 26 new Set(["fi-strategy", "fi-brief", "f-projects", "sh-2", "d-thesis"]), 33 - ); 34 - const [loading, setLoading] = useState(false); 27 + ) 28 + const [loading] = useState(false) 35 29 36 - const currentPanel = panels[panels.length - 1]; 30 + const currentPanel = panels[panels.length - 1] 37 31 38 32 const openSection = (type: SectionType, title: string) => { 39 - setPanels([{ type, title }]); 40 - }; 33 + setPanels([{ type, title }]) 34 + } 41 35 42 36 const openItem = (item: FileItem) => { 43 37 if (item.kind === "folder") { ··· 49 43 title: item.name, 50 44 itemCount: item.items, 51 45 }, 52 - ]); 46 + ]) 53 47 } 54 - }; 48 + } 55 49 56 50 const goToPanel = (index: number) => { 57 - setPanels((prev) => prev.slice(0, index + 1)); 58 - }; 51 + setPanels((prev) => prev.slice(0, index + 1)) 52 + } 59 53 60 54 const closePanel = () => { 61 - setPanels((prev) => prev.slice(0, -1)); 62 - }; 55 + setPanels((prev) => prev.slice(0, -1)) 56 + } 63 57 64 58 // TODO (#3): toggleStar and other callbacks are prop-drilled 4 levels deep 65 59 // (cabinet → PanelStack → PanelContent → FileListRow). Extract a 66 60 // CabinetContext to provide actions + starredIds via context instead. 67 61 const toggleStar = (id: string) => { 68 - setStarredIds((prev) => { 69 - const next = new Set(prev); 70 - next.has(id) ? next.delete(id) : next.add(id); 71 - return next; 72 - }); 73 - }; 62 + setStarredIds((prev) => 63 + prev.has(id) ? new Set([...prev].filter((x) => x !== id)) : new Set([...prev, id]), 64 + ) 65 + } 74 66 75 67 // TODO (#4): toggleStar, openItem, goToPanel, closePanel are all redefined 76 68 // every render — wrap in useCallback so leaf components can be memoized 77 69 // with React.memo(). Alternatively, CabinetContext eliminates the issue. 78 70 79 71 return ( 80 - <div className="flex h-screen overflow-hidden bg-base-300 font-sans"> 72 + <div className="bg-base-300 flex h-screen overflow-hidden font-sans"> 81 73 <Sidebar 82 74 activePanelType={currentPanel.type} 83 75 panelDepth={panels.length} ··· 102 94 /> 103 95 </main> 104 96 </div> 105 - ); 97 + ) 106 98 } 107 99 108 100 export const Route = createFileRoute("/cabinet")({ 109 101 beforeLoad: () => { 110 - const state = useAuthStore.getState(); 102 + const state = useAuthStore.getState() 111 103 if (state.phase !== "ready" && state.phase !== "awaiting_identity") { 112 - throw redirect({ to: "/login" }); 104 + throw redirect({ to: "/login" }) 113 105 } 114 106 }, 115 107 component: CabinetLayout, 116 - }); 108 + })
+17 -21
web/src/routes/index.tsx
··· 1 - import { createFileRoute, Link } from "@tanstack/react-router"; 2 - import { ArrowRight } from "@phosphor-icons/react"; 3 - import { OpakeLogo } from "@/components/OpakeLogo"; 1 + import { createFileRoute, Link } from "@tanstack/react-router" 2 + import { ArrowRightIcon } from "@phosphor-icons/react" 3 + import { OpakeLogo } from "@/components/OpakeLogo" 4 4 5 5 function LandingPage() { 6 6 return ( 7 - <div className="flex min-h-screen flex-col bg-base-300 font-sans"> 7 + <div className="bg-base-300 flex min-h-screen flex-col font-sans"> 8 8 {/* Nav */} 9 - <nav className="fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b border-base-300/50 bg-base-300/85 px-10 py-3.5 backdrop-blur-[14px]"> 9 + <nav className="border-base-300/50 bg-base-300/85 fixed inset-x-0 top-0 z-50 flex items-center justify-between border-b px-10 py-3.5 backdrop-blur-[14px]"> 10 10 <OpakeLogo /> 11 - <Link 12 - to="/cabinet" 13 - className="btn btn-neutral btn-sm gap-2 text-ui" 14 - > 11 + <Link to="/cabinet" className="btn btn-neutral btn-sm text-ui gap-2"> 15 12 Open the Cabinet 16 - <ArrowRight size={14} /> 13 + <ArrowRightIcon size={14} /> 17 14 </Link> 18 15 </nav> 19 16 20 17 {/* Hero */} 21 18 <section className="flex min-h-screen flex-col items-center justify-center px-10 pt-30 pb-20"> 22 19 {/* Ornamental rule */} 23 - <div className="divider mb-8 w-80 self-center text-caption uppercase tracking-[0.18em] text-primary before:bg-border-accent after:bg-border-accent"> 20 + <div className="divider text-caption text-primary before:bg-border-accent after:bg-border-accent mb-8 w-80 self-center tracking-[0.18em] uppercase"> 24 21 Built on the AT Protocol 25 22 </div> 26 23 27 - <h1 className="mb-7 max-w-205 text-center font-display text-[clamp(3.4rem,7.5vw,6.2rem)] leading-[1.04] tracking-tight font-normal text-base-content"> 28 - Your data,{" "} 29 - <em className="text-primary">freely shared</em>, 24 + <h1 className="font-display text-base-content mb-7 max-w-205 text-center text-[clamp(3.4rem,7.5vw,6.2rem)] leading-[1.04] font-normal tracking-tight"> 25 + Your data, <em className="text-primary">freely shared</em>, 30 26 <br /> 31 27 privately kept. 32 28 </h1> 33 29 34 - <p className="mb-10 max-w-130 text-center text-[1.05rem] leading-[1.75] text-secondary"> 35 - Opake exists because privacy and collaboration should not be a 36 - tradeoff. Your files — encrypted, owned, shared on your terms — through 37 - decentralised identity, with no central authority in between. 30 + <p className="text-secondary mb-10 max-w-130 text-center text-[1.05rem] leading-[1.75]"> 31 + Opake exists because privacy and collaboration should not be a tradeoff. Your files — 32 + encrypted, owned, shared on your terms — through decentralised identity, with no central 33 + authority in between. 38 34 </p> 39 35 40 36 <div className="flex items-center gap-3.5"> ··· 43 39 className="btn btn-neutral gap-2.5 shadow-[0_4px_20px_oklch(0.155_0.035_70/0.18)]" 44 40 > 45 41 Open the Cabinet 46 - <ArrowRight size={15} /> 42 + <ArrowRightIcon size={15} /> 47 43 </Link> 48 44 <a 49 45 href="#about" ··· 54 50 </div> 55 51 </section> 56 52 </div> 57 - ); 53 + ) 58 54 } 59 55 60 56 export const Route = createFileRoute("/")({ 61 57 component: LandingPage, 62 - }); 58 + })
+25 -39
web/src/routes/login.tsx
··· 1 - import { useState } from "react"; 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 - import { OpakeLogo } from "@/components/OpakeLogo"; 4 - import { useAuthStore } from "@/stores/auth"; 1 + import { useState } from "react" 2 + import { createFileRoute, redirect } from "@tanstack/react-router" 3 + import { OpakeLogo } from "@/components/OpakeLogo" 4 + import { useAuthStore } from "@/stores/auth" 5 5 6 6 function LoginPage() { 7 - const navigate = useNavigate(); 8 - const phase = useAuthStore((s) => s.phase); 9 - const startLogin = useAuthStore((s) => s.startLogin); 10 - const [handle, setHandle] = useState(""); 11 - const isLoading = phase === "authenticating"; 12 - const errorMessage = phase === "error" ? useAuthStore.getState() : null; 7 + const phase = useAuthStore((s) => s.phase) 8 + const startLogin = useAuthStore((s) => s.startLogin) 9 + const [handle, setHandle] = useState("") 10 + const isLoading = phase === "authenticating" 11 + const errorMessage = phase === "error" ? useAuthStore.getState() : null 13 12 14 - const handleSubmit = async (e: React.FormEvent) => { 15 - e.preventDefault(); 16 - if (!handle.trim()) return; 17 - await startLogin(handle.trim()); 13 + const handleSubmit = (e: React.SyntheticEvent<HTMLFormElement>) => { 14 + e.preventDefault() 15 + if (!handle.trim()) return 16 + void startLogin(handle.trim()) 18 17 // startLogin redirects to the AS — we won't reach here unless it errors 19 - }; 18 + } 20 19 21 20 return ( 22 - <div className="flex min-h-screen flex-col items-center justify-center bg-base-300 font-sans"> 21 + <div className="bg-base-300 flex min-h-screen flex-col items-center justify-center font-sans"> 23 22 <div className="mb-8"> 24 23 <OpakeLogo size="lg" /> 25 24 </div> 26 - <form 27 - onSubmit={handleSubmit} 28 - className="card card-bordered w-80 bg-base-100 p-6" 29 - > 30 - <h1 className="mb-1 text-ui font-medium text-base-content"> 31 - Sign in to Opake 32 - </h1> 33 - <p className="mb-5 text-caption text-text-muted"> 25 + <form onSubmit={handleSubmit} className="card card-bordered bg-base-100 w-80 p-6"> 26 + <h1 className="text-ui text-base-content mb-1 font-medium">Sign in to Opake</h1> 27 + <p className="text-caption text-text-muted mb-5"> 34 28 Enter your AT Protocol handle to continue. 35 29 </p> 36 30 <label className="input input-bordered mb-3 flex items-center gap-2"> ··· 46 40 /> 47 41 </label> 48 42 {phase === "error" && errorMessage && ( 49 - <p className="mb-3 text-caption text-error" role="alert"> 43 + <p className="text-caption text-error mb-3" role="alert"> 50 44 {"message" in errorMessage ? errorMessage.message : "Login failed"} 51 45 </p> 52 46 )} 53 - <button 54 - type="submit" 55 - className="btn btn-neutral w-full" 56 - disabled={isLoading} 57 - > 58 - {isLoading ? ( 59 - <span className="loading loading-spinner loading-sm" /> 60 - ) : ( 61 - "Sign in" 62 - )} 47 + <button type="submit" className="btn btn-neutral w-full" disabled={isLoading}> 48 + {isLoading ? <span className="loading loading-spinner loading-sm" /> : "Sign in"} 63 49 </button> 64 50 </form> 65 51 </div> 66 - ); 52 + ) 67 53 } 68 54 69 55 export const Route = createFileRoute("/login")({ 70 56 beforeLoad: () => { 71 - const state = useAuthStore.getState(); 72 - if (state.phase === "ready") throw redirect({ to: "/cabinet" }); 57 + const state = useAuthStore.getState() 58 + if (state.phase === "ready") throw redirect({ to: "/cabinet" }) 73 59 }, 74 60 component: LoginPage, 75 - }); 61 + })
+36 -39
web/src/routes/oauth.callback.tsx
··· 1 - import { useEffect, useRef } from "react"; 2 - import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; 3 - import { OpakeLogo } from "@/components/OpakeLogo"; 4 - import { useAuthStore } from "@/stores/auth"; 1 + import { useEffect, useRef } from "react" 2 + import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router" 3 + import { OpakeLogo } from "@/components/OpakeLogo" 4 + import { useAuthStore } from "@/stores/auth" 5 5 6 6 function OAuthCallbackPage() { 7 - const navigate = useNavigate(); 8 - const phase = useAuthStore((s) => s.phase); 9 - const completeLogin = useAuthStore((s) => s.completeLogin); 10 - const errorMessage = phase === "error" ? (useAuthStore.getState() as { message: string }).message : null; 7 + const navigate = useNavigate() 8 + const phase = useAuthStore((s) => s.phase) 9 + const completeLogin = useAuthStore((s) => s.completeLogin) 10 + const errorMessage = 11 + phase === "error" ? (useAuthStore.getState() as { message: string }).message : null 11 12 12 - const hasStartedRef = useRef(false); 13 + const hasStartedRef = useRef(false) 13 14 14 15 useEffect(() => { 15 - if (hasStartedRef.current) return; 16 - hasStartedRef.current = true; 16 + if (hasStartedRef.current) return 17 + hasStartedRef.current = true 17 18 18 - const params = new URLSearchParams(window.location.search); 19 - const code = params.get("code"); 20 - const state = params.get("state"); 19 + const params = new URLSearchParams(window.location.search) 20 + const code = params.get("code") 21 + const state = params.get("state") 21 22 22 23 // Strip query params from URL immediately 23 - window.history.replaceState({}, "", window.location.pathname); 24 + window.history.replaceState({}, "", window.location.pathname) 24 25 25 26 if (!code || !state) { 26 27 useAuthStore.setState({ 27 28 phase: "error", 28 29 message: "Missing authorization code or state parameter.", 29 - }); 30 - return; 30 + }) 31 + return 31 32 } 32 33 33 - completeLogin(code, state); 34 - }, [completeLogin]); 34 + void completeLogin(code, state) 35 + }, [completeLogin]) 35 36 36 37 useEffect(() => { 37 38 if (phase === "ready") { 38 - navigate({ to: "/cabinet" }); 39 + void navigate({ to: "/cabinet" }) 39 40 } else if (phase === "awaiting_identity") { 40 - navigate({ to: "/cabinet/devices" }); 41 + void navigate({ to: "/cabinet/devices" }) 41 42 } 42 - }, [phase, navigate]); 43 + }, [phase, navigate]) 43 44 44 45 return ( 45 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 46 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 46 47 <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 47 48 <OpakeLogo size="lg" /> 48 49 49 - {(phase === "authenticating" || phase === "initializing" || phase === "unauthenticated") && ( 50 + {(phase === "authenticating" || 51 + phase === "initializing" || 52 + phase === "unauthenticated") && ( 50 53 <div className="flex flex-col items-center gap-4"> 51 54 <span className="loading loading-spinner loading-lg text-primary" /> 52 - <p className="text-sm text-base-content/60">Completing login…</p> 55 + <p className="text-base-content/60 text-sm">Completing login…</p> 53 56 </div> 54 57 )} 55 58 56 59 {phase === "error" && ( 57 60 <div className="flex flex-col items-center gap-6 text-center"> 58 - <div className="flex h-16 w-16 items-center justify-center rounded-full bg-error/20"> 61 + <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 59 62 <svg 60 - className="h-8 w-8 text-error" 63 + className="text-error h-8 w-8" 61 64 fill="none" 62 65 viewBox="0 0 24 24" 63 66 stroke="currentColor" 64 67 strokeWidth={2} 65 68 aria-hidden="true" 66 69 > 67 - <path 68 - strokeLinecap="round" 69 - strokeLinejoin="round" 70 - d="M6 18L18 6M6 6l12 12" 71 - /> 70 + <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 72 71 </svg> 73 72 </div> 74 73 75 74 <div className="flex flex-col gap-2"> 76 - <h1 className="text-2xl font-semibold text-base-content"> 77 - Login failed 78 - </h1> 75 + <h1 className="text-base-content text-2xl font-semibold">Login failed</h1> 79 76 <p className="text-base-content/60">{errorMessage}</p> 80 77 </div> 81 78 ··· 86 83 )} 87 84 </div> 88 85 </div> 89 - ); 86 + ) 90 87 } 91 88 92 89 export const Route = createFileRoute("/oauth/callback")({ 93 90 beforeLoad: () => { 94 - const state = useAuthStore.getState(); 95 - if (state.phase === "ready") throw redirect({ to: "/cabinet" }); 91 + const state = useAuthStore.getState() 92 + if (state.phase === "ready") throw redirect({ to: "/cabinet" }) 96 93 }, 97 94 component: OAuthCallbackPage, 98 - }); 95 + })
+32 -55
web/src/routes/oauth.cli-callback.tsx
··· 1 - import { CheckIcon } from "@phosphor-icons/react"; 2 - import { useEffect, useState } from "react"; 3 - import { createFileRoute } from "@tanstack/react-router"; 4 - import { OpakeLogo } from "@/components/OpakeLogo"; 1 + import { CheckIcon } from "@phosphor-icons/react" 2 + import { useMemo, useEffect } from "react" 3 + import { createFileRoute } from "@tanstack/react-router" 4 + import { OpakeLogo } from "@/components/OpakeLogo" 5 5 6 - type CallbackState = "loading" | "success" | "error"; 6 + type CallbackResult = 7 + | { state: "success"; errorMessage: "" } 8 + | { state: "error"; errorMessage: string } 7 9 8 10 function OAuthCallbackPage() { 9 - const [state, setState] = useState<CallbackState>("loading"); 10 - const [errorMessage, setErrorMessage] = useState<string>(""); 11 - 12 - useEffect(() => { 13 - const params = new URLSearchParams(window.location.search); 14 - const error = params.get("error"); 15 - 16 - // Strip query params from URL 17 - window.history.replaceState({}, "", window.location.pathname); 18 - 11 + const { state, errorMessage } = useMemo<CallbackResult>(() => { 12 + const params = new URLSearchParams(window.location.search) 13 + const error = params.get("error") 19 14 if (error) { 20 - setErrorMessage(error); 21 - setState("error"); 22 - return; 15 + return { state: "error", errorMessage: error } 23 16 } 17 + return { state: "success", errorMessage: "" } 18 + }, []) 24 19 25 - // Just show success - CLI login doesn't log you into web 26 - setState("success"); 27 - }, []); 20 + useEffect(() => { 21 + // Strip query params from URL after reading them 22 + window.history.replaceState({}, "", window.location.pathname) 23 + }, []) 28 24 29 25 return ( 30 - <div className="flex min-h-screen items-center justify-center bg-base-300 font-sans"> 26 + <div className="bg-base-300 flex min-h-screen items-center justify-center font-sans"> 31 27 <div className="flex w-full max-w-md flex-col items-center gap-8 px-6 py-12"> 32 28 <OpakeLogo size="lg" /> 33 29 34 - {state === "loading" && ( 35 - <div className="flex flex-col items-center gap-4"> 36 - <span className="loading loading-spinner loading-lg text-primary" /> 37 - <p className="text-sm text-base-content/60">Completing authentication…</p> 38 - </div> 39 - )} 40 - 41 30 {state === "success" && ( 42 31 <div className="flex flex-col items-center gap-6 text-center"> 43 - <div className="flex h-16 w-16 items-center justify-center rounded-full bg-success/20"> 32 + <div className="bg-success/20 flex h-16 w-16 items-center justify-center rounded-full"> 44 33 <CheckIcon size={32} /> 45 34 </div> 46 35 47 36 <div className="flex flex-col gap-2"> 48 - <h1 className="text-2xl font-semibold text-base-content"> 49 - CLI login successful 50 - </h1> 37 + <h1 className="text-base-content text-2xl font-semibold">CLI login successful</h1> 51 38 </div> 52 39 53 - <div className="mt-2 flex flex-col gap-3 text-sm text-base-content/50"> 54 - <p> 55 - You can close this tab and return to your terminal. 56 - </p> 40 + <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 41 + <p>You can close this tab and return to your terminal.</p> 57 42 <p> 58 - <span className="font-medium text-base-content/70">Note:</span> This 59 - logs you into the CLI only. The web app requires a separate login. 43 + <span className="text-base-content/70 font-medium">Note:</span> This logs you into 44 + the CLI only. The web app requires a separate login. 60 45 </p> 61 46 </div> 62 47 </div> ··· 64 49 65 50 {state === "error" && ( 66 51 <div className="flex flex-col items-center gap-6 text-center"> 67 - <div className="flex h-16 w-16 items-center justify-center rounded-full bg-error/20"> 52 + <div className="bg-error/20 flex h-16 w-16 items-center justify-center rounded-full"> 68 53 <svg 69 - className="h-8 w-8 text-error" 54 + className="text-error h-8 w-8" 70 55 fill="none" 71 56 viewBox="0 0 24 24" 72 57 stroke="currentColor" 73 58 strokeWidth={2} 74 59 > 75 - <path 76 - strokeLinecap="round" 77 - strokeLinejoin="round" 78 - d="M6 18L18 6M6 6l12 12" 79 - /> 60 + <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> 80 61 </svg> 81 62 </div> 82 63 83 64 <div className="flex flex-col gap-2"> 84 - <h1 className="text-2xl font-semibold text-base-content"> 85 - CLI login failed 86 - </h1> 65 + <h1 className="text-base-content text-2xl font-semibold">CLI login failed</h1> 87 66 <p className="text-base-content/60">{errorMessage}</p> 88 67 </div> 89 68 90 - <div className="mt-2 flex flex-col gap-3 text-sm text-base-content/50"> 91 - <p> 92 - Please try again from your terminal. 93 - </p> 69 + <div className="text-base-content/50 mt-2 flex flex-col gap-3 text-sm"> 70 + <p>Please try again from your terminal.</p> 94 71 </div> 95 72 </div> 96 73 )} 97 74 </div> 98 75 </div> 99 - ); 76 + ) 100 77 } 101 78 102 79 export const Route = createFileRoute("/oauth/cli-callback")({ 103 80 component: OAuthCallbackPage, 104 - }); 81 + })
+211 -215
web/src/stores/auth.ts
··· 4 4 // ↘ awaiting_identity 5 5 // ↘ error 6 6 7 - import { create } from "zustand"; 8 - import type { OAuthSession, Config } from "@/lib/storage-types"; 9 - import { IndexedDbStorage } from "@/lib/indexeddb-storage"; 10 - import type { Remote } from "comlink"; 11 - import type { CryptoApi } from "@/workers/crypto.worker"; 12 - import { getCryptoWorker } from "@/lib/worker"; 13 - import { authenticatedXrpc } from "@/lib/api"; 7 + import { create } from "zustand" 8 + import { immer } from "zustand/middleware/immer" 9 + import type { OAuthSession, Config } from "@/lib/storage-types" 10 + import { IndexedDbStorage } from "@/lib/indexeddb-storage" 11 + import { getCryptoWorker } from "@/lib/worker" 12 + import { authenticatedXrpc } from "@/lib/api" 14 13 import { 15 14 resolveHandleToPds, 16 15 discoverAuthorizationServer, ··· 24 23 loadPendingState, 25 24 clearPendingState, 26 25 generateCsrfState, 27 - } from "@/lib/oauth"; 26 + } from "@/lib/oauth" 28 27 29 28 // --------------------------------------------------------------------------- 30 29 // State types ··· 36 35 | { phase: "authenticating" } 37 36 | { phase: "ready"; did: string; handle: string; pdsUrl: string } 38 37 | { phase: "awaiting_identity"; did: string; handle: string; pdsUrl: string } 39 - | { phase: "error"; message: string }; 38 + | { phase: "error"; message: string } 40 39 41 40 interface AuthActions { 42 - boot(): Promise<void>; 43 - startLogin(handle: string): Promise<void>; 44 - completeLogin(code: string, state: string): Promise<void>; 45 - logout(): Promise<void>; 41 + boot(): Promise<void> 42 + startLogin(handle: string): Promise<void> 43 + completeLogin(code: string, state: string): Promise<void> 44 + logout(): Promise<void> 46 45 } 47 46 48 - type AuthState = AuthPhase & AuthActions; 47 + type AuthState = AuthPhase & AuthActions 49 48 50 49 // --------------------------------------------------------------------------- 51 50 // Singletons (created once, shared across store actions) 52 51 // --------------------------------------------------------------------------- 53 52 54 - const storage = new IndexedDbStorage(); 53 + const storage = new IndexedDbStorage() 55 54 56 55 // --------------------------------------------------------------------------- 57 56 // Helpers ··· 62 61 pdsUrl: string, 63 62 did: string, 64 63 session: OAuthSession, 65 - _worker: Remote<CryptoApi>, 66 64 ): Promise<boolean> { 67 65 try { 68 66 await authenticatedXrpc( ··· 72 70 method: "GET", 73 71 }, 74 72 session, 75 - ); 76 - return true; 73 + ) 74 + return true 77 75 } catch (error) { 78 - console.warn("[auth] publicKey/self lookup failed (treating as new account):", error); 79 - return false; 76 + console.warn("[auth] publicKey/self lookup failed (treating as new account):", error) 77 + return false 80 78 } 81 79 } 82 80 ··· 84 82 // Store 85 83 // --------------------------------------------------------------------------- 86 84 87 - export const useAuthStore = create<AuthState>((set, get) => ({ 88 - phase: "initializing", 85 + export const useAuthStore = create<AuthState>()( 86 + immer((set, get) => ({ 87 + phase: "initializing", 89 88 90 - boot: async () => { 91 - try { 92 - const config = await storage.loadConfig(); 93 - if (!config.defaultDid) { 94 - set({ phase: "unauthenticated" }); 95 - return; 96 - } 89 + boot: async () => { 90 + try { 91 + const config = await storage.loadConfig() 92 + if (!config.defaultDid) { 93 + set({ phase: "unauthenticated" }) 94 + return 95 + } 97 96 98 - const did = config.defaultDid; 99 - const account = config.accounts[did]; 100 - if (!account) { 101 - set({ phase: "unauthenticated" }); 102 - return; 103 - } 97 + const did = config.defaultDid 98 + const account = config.accounts[did] as 99 + | import("@/lib/storage-types").AccountConfig 100 + | undefined 101 + if (!account) { 102 + set({ phase: "unauthenticated" }) 103 + return 104 + } 104 105 105 - // Verify session exists 106 - await storage.loadSession(did); 106 + // Verify session exists 107 + await storage.loadSession(did) 107 108 108 - // Check if identity exists locally 109 - const hasIdentity = await storage 110 - .loadIdentity(did) 111 - .then(() => true) 112 - .catch(() => false); 109 + // Check if identity exists locally 110 + const hasIdentity = await storage 111 + .loadIdentity(did) 112 + .then(() => true) 113 + .catch(() => false) 113 114 114 - if (hasIdentity) { 115 - set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }); 116 - } else { 117 - set({ phase: "awaiting_identity", did, handle: account.handle, pdsUrl: account.pdsUrl }); 115 + if (hasIdentity) { 116 + set({ phase: "ready", did, handle: account.handle, pdsUrl: account.pdsUrl }) 117 + } else { 118 + set({ phase: "awaiting_identity", did, handle: account.handle, pdsUrl: account.pdsUrl }) 119 + } 120 + } catch { 121 + set({ phase: "unauthenticated" }) 118 122 } 119 - } catch { 120 - set({ phase: "unauthenticated" }); 121 - } 122 - }, 123 + }, 123 124 124 - startLogin: async (handle: string) => { 125 - set({ phase: "authenticating" }); 126 - const worker = getCryptoWorker(); 125 + startLogin: async (handle: string) => { 126 + set({ phase: "authenticating" }) 127 + const worker = getCryptoWorker() 127 128 128 - try { 129 - // Resolve handle → PDS 130 - const { did: _did, pdsUrl } = await resolveHandleToPds(handle); 129 + try { 130 + // Resolve handle → PDS 131 + const { pdsUrl } = await resolveHandleToPds(handle) 131 132 132 - // OAuth discovery 133 - const asm = await discoverAuthorizationServer(pdsUrl); 133 + // OAuth discovery 134 + const asm = await discoverAuthorizationServer(pdsUrl) 134 135 135 - // Generate crypto material 136 - const dpopKey = await worker.generateDpopKeyPair(); 137 - const pkce = await worker.generatePkce(); 138 - const csrfState = generateCsrfState(); 136 + // Generate crypto material 137 + const dpopKey = await worker.generateDpopKeyPair() 138 + const pkce = await worker.generatePkce() 139 + const csrfState = generateCsrfState() 139 140 140 - // Client ID + redirect URI 141 - const redirectUri = buildRedirectUri(); 142 - const clientId = buildClientId(redirectUri); 141 + // Client ID + redirect URI 142 + const redirectUri = buildRedirectUri() 143 + const clientId = buildClientId(redirectUri) 143 144 144 - const parEndpoint = 145 - asm.pushed_authorization_request_endpoint ?? asm.token_endpoint; 145 + const parEndpoint = asm.pushed_authorization_request_endpoint ?? asm.token_endpoint 146 146 147 - // PAR 148 - const { requestUri, dpopNonce } = await pushedAuthorizationRequest( 149 - parEndpoint, 150 - clientId, 151 - redirectUri, 152 - pkce.challenge, 153 - csrfState, 154 - dpopKey, 155 - null, 156 - worker, 157 - ); 147 + // PAR 148 + const { requestUri, dpopNonce } = await pushedAuthorizationRequest( 149 + parEndpoint, 150 + clientId, 151 + redirectUri, 152 + pkce.challenge, 153 + csrfState, 154 + dpopKey, 155 + null, 156 + worker, 157 + ) 158 158 159 - // Persist pre-redirect state 160 - savePendingState({ 161 - pdsUrl, 162 - handle, 163 - dpopKey, 164 - pkceVerifier: pkce.verifier, 165 - csrfState, 166 - tokenEndpoint: asm.token_endpoint, 167 - clientId, 168 - dpopNonce, 169 - }); 159 + // Persist pre-redirect state 160 + savePendingState({ 161 + pdsUrl, 162 + handle, 163 + dpopKey, 164 + pkceVerifier: pkce.verifier, 165 + csrfState, 166 + tokenEndpoint: asm.token_endpoint, 167 + clientId, 168 + dpopNonce, 169 + }) 170 170 171 - // Redirect to AS 172 - const authUrl = buildAuthorizationUrl( 173 - asm.authorization_endpoint, 174 - clientId, 175 - requestUri, 176 - ); 177 - window.location.href = authUrl; 178 - } catch (error) { 179 - console.error("[auth] startLogin failed:", error); 180 - const message = error instanceof Error ? error.message : String(error); 181 - set({ phase: "error", message }); 182 - } 183 - }, 171 + // Redirect to AS 172 + const authUrl = buildAuthorizationUrl(asm.authorization_endpoint, clientId, requestUri) 173 + window.location.href = authUrl 174 + } catch (error) { 175 + console.error("[auth] startLogin failed:", error) 176 + const message = error instanceof Error ? error.message : String(error) 177 + set({ phase: "error", message }) 178 + } 179 + }, 184 180 185 - completeLogin: async (code: string, callbackState: string) => { 186 - console.debug("[auth] completeLogin called, current phase:", get().phase); 187 - if (get().phase === "ready") return; 188 - set({ phase: "authenticating" }); 189 - const worker = getCryptoWorker(); 181 + completeLogin: async (code: string, callbackState: string) => { 182 + console.debug("[auth] completeLogin called, current phase:", get().phase) 183 + if (get().phase === "ready") return 184 + set({ phase: "authenticating" }) 185 + const worker = getCryptoWorker() 190 186 191 - try { 192 - const pending = loadPendingState(); 193 - console.debug("[auth] pending state:", pending ? "loaded" : "missing"); 194 - if (!pending) throw new Error("No pending OAuth state — start login again"); 187 + try { 188 + const pending = loadPendingState() 189 + console.debug("[auth] pending state:", pending ? "loaded" : "missing") 190 + if (!pending) throw new Error("No pending OAuth state — start login again") 195 191 196 - // CSRF verification 197 - if (callbackState !== pending.csrfState) { 198 - clearPendingState(); 199 - throw new Error("OAuth state mismatch — possible CSRF attack"); 200 - } 192 + // CSRF verification 193 + if (callbackState !== pending.csrfState) { 194 + clearPendingState() 195 + throw new Error("OAuth state mismatch — possible CSRF attack") 196 + } 201 197 202 - console.debug("[auth] CSRF ok, exchanging code"); 203 - const redirectUri = buildRedirectUri(); 198 + console.debug("[auth] CSRF ok, exchanging code") 199 + const redirectUri = buildRedirectUri() 204 200 205 - // Exchange code for tokens 206 - const { tokenResponse, dpopNonce } = await exchangeCode( 207 - pending.tokenEndpoint, 208 - pending.clientId, 209 - code, 210 - redirectUri, 211 - pending.pkceVerifier, 212 - pending.dpopKey, 213 - pending.dpopNonce, 214 - worker, 215 - ); 201 + // Exchange code for tokens 202 + const { tokenResponse, dpopNonce } = await exchangeCode( 203 + pending.tokenEndpoint, 204 + pending.clientId, 205 + code, 206 + redirectUri, 207 + pending.pkceVerifier, 208 + pending.dpopKey, 209 + pending.dpopNonce, 210 + worker, 211 + ) 216 212 217 - console.debug("[auth] token exchange done, sub:", tokenResponse.sub); 218 - const did = tokenResponse.sub; 219 - if (!did) throw new Error("Token response missing `sub` claim"); 213 + console.debug("[auth] token exchange done, sub:", tokenResponse.sub) 214 + const did = tokenResponse.sub 215 + if (!did) throw new Error("Token response missing `sub` claim") 220 216 221 - const timestamp = Math.floor(Date.now() / 1000); 222 - const expiresAt = tokenResponse.expires_in 223 - ? timestamp + tokenResponse.expires_in 224 - : null; 217 + const timestamp = Math.floor(Date.now() / 1000) 218 + const expiresAt = tokenResponse.expires_in ? timestamp + tokenResponse.expires_in : null 225 219 226 - const session: OAuthSession = { 227 - type: "oauth", 228 - did, 229 - handle: pending.handle, 230 - accessToken: tokenResponse.access_token, 231 - refreshToken: tokenResponse.refresh_token ?? "", 232 - dpopKey: pending.dpopKey, 233 - tokenEndpoint: pending.tokenEndpoint, 234 - dpopNonce, 235 - expiresAt, 236 - clientId: pending.clientId, 237 - }; 220 + const session: Readonly<OAuthSession> = { 221 + type: "oauth", 222 + did, 223 + handle: pending.handle, 224 + accessToken: tokenResponse.access_token, 225 + refreshToken: tokenResponse.refresh_token ?? "", 226 + dpopKey: pending.dpopKey, 227 + tokenEndpoint: pending.tokenEndpoint, 228 + dpopNonce, 229 + expiresAt, 230 + clientId: pending.clientId, 231 + } 238 232 239 - console.debug("[auth] checking existing publicKey/self"); 240 - // Check if this DID already has a publicKey/self record on the PDS. 241 - // If so, another device owns the identity — don't overwrite it. 242 - const hasExistingKey = await checkExistingPublicKey( 243 - pending.pdsUrl, 244 - did, 245 - session, 246 - worker, 247 - ); 233 + console.debug("[auth] checking existing publicKey/self") 234 + // Check if this DID already has a publicKey/self record on the PDS. 235 + // If so, another device owns the identity — don't overwrite it. 236 + const hasExistingKey = await checkExistingPublicKey(pending.pdsUrl, did, session) 248 237 249 - // Persist config + session regardless 250 - const config: Config = await storage.loadConfig().catch(() => ({ 251 - defaultDid: null, 252 - accounts: {}, 253 - appviewUrl: null, 254 - })); 255 - config.defaultDid = did; 256 - config.accounts[did] = { pdsUrl: pending.pdsUrl, handle: pending.handle }; 238 + // Persist config + session regardless 239 + const existingConfig: Readonly<Config> = await storage.loadConfig().catch(() => ({ 240 + defaultDid: null, 241 + accounts: {}, 242 + appviewUrl: null, 243 + })) 244 + const config: Readonly<Config> = { 245 + ...existingConfig, 246 + defaultDid: did, 247 + accounts: { 248 + ...existingConfig.accounts, 249 + [did]: { pdsUrl: pending.pdsUrl, handle: pending.handle }, 250 + }, 251 + } 257 252 258 - await storage.saveConfig(config); 259 - await storage.saveSession(did, session); 253 + await storage.saveConfig(config) 254 + await storage.saveSession(did, session) 260 255 261 - clearPendingState(); 256 + clearPendingState() 262 257 263 - console.debug("[auth] hasExistingKey:", hasExistingKey); 258 + console.debug("[auth] hasExistingKey:", hasExistingKey) 264 259 265 - if (hasExistingKey) { 266 - // Identity exists on PDS but not locally — user needs to pair 267 - set({ 268 - phase: "awaiting_identity", 269 - did, 270 - handle: pending.handle, 271 - pdsUrl: pending.pdsUrl, 272 - }); 273 - } else { 274 - // Fresh account — generate identity and publish key 275 - const identity = await worker.generateIdentity(did); 260 + if (hasExistingKey) { 261 + // Identity exists on PDS but not locally — user needs to pair 262 + set({ 263 + phase: "awaiting_identity", 264 + did, 265 + handle: pending.handle, 266 + pdsUrl: pending.pdsUrl, 267 + }) 268 + } else { 269 + // Fresh account — generate identity and publish key 270 + const identity = await worker.generateIdentity(did) 276 271 277 - await publishPublicKey( 278 - pending.pdsUrl, 279 - did, 280 - identity.public_key, 281 - identity.verify_key, 282 - session.accessToken, 283 - session.dpopKey, 284 - session.dpopNonce, 285 - worker, 286 - ); 272 + await publishPublicKey( 273 + pending.pdsUrl, 274 + did, 275 + identity.public_key, 276 + identity.verify_key, 277 + session.accessToken, 278 + session.dpopKey, 279 + session.dpopNonce, 280 + worker, 281 + ) 287 282 288 - await storage.saveIdentity(did, identity); 283 + await storage.saveIdentity(did, identity) 289 284 290 - set({ 291 - phase: "ready", 292 - did, 293 - handle: pending.handle, 294 - pdsUrl: pending.pdsUrl, 295 - }); 285 + set({ 286 + phase: "ready", 287 + did, 288 + handle: pending.handle, 289 + pdsUrl: pending.pdsUrl, 290 + }) 291 + } 292 + } catch (error) { 293 + console.error("[auth] completeLogin failed:", error) 294 + clearPendingState() 295 + const message = error instanceof Error ? error.message : String(error) 296 + set({ phase: "error", message }) 296 297 } 297 - } catch (error) { 298 - console.error("[auth] completeLogin failed:", error); 299 - clearPendingState(); 300 - const message = error instanceof Error ? error.message : String(error); 301 - set({ phase: "error", message }); 302 - } 303 - }, 298 + }, 304 299 305 - logout: async () => { 306 - const current = get(); 307 - if (current.phase === "ready" || current.phase === "awaiting_identity") { 308 - try { 309 - await storage.removeAccount(current.did); 310 - } catch { 311 - // best-effort cleanup 300 + logout: async () => { 301 + const current = get() 302 + if (current.phase === "ready" || current.phase === "awaiting_identity") { 303 + try { 304 + await storage.removeAccount(current.did) 305 + } catch { 306 + // best-effort cleanup 307 + } 312 308 } 313 - } 314 - set({ phase: "unauthenticated" }); 315 - }, 316 - })); 309 + set({ phase: "unauthenticated" }) 310 + }, 311 + })), 312 + )
+34 -42
web/src/workers/crypto.worker.ts
··· 1 - import * as Comlink from "comlink"; 1 + import * as Comlink from "comlink" 2 2 import init, { 3 3 bindingCheck, 4 4 generateContentKey, ··· 13 13 generatePkce as wasmGeneratePkce, 14 14 generateIdentity as wasmGenerateIdentity, 15 15 generateEphemeralKeypair as wasmGenerateEphemeralKeypair, 16 - } from "@/wasm/opake-wasm/opake"; 17 - import type { EncryptedPayload, WrappedKey, DpopKeyPair, PkceChallenge, EphemeralKeypair } from "@/lib/crypto-types"; 18 - import type { Identity } from "@/lib/storage-types"; 16 + } from "@/wasm/opake-wasm/opake" 17 + import type { 18 + EncryptedPayload, 19 + WrappedKey, 20 + DpopKeyPair, 21 + PkceChallenge, 22 + EphemeralKeypair, 23 + } from "@/lib/crypto-types" 24 + import type { Identity } from "@/lib/storage-types" 19 25 20 - console.debug("[worker] initializing WASM"); 21 - await init(); 22 - console.debug("[worker] ready, binding check:", bindingCheck()); 26 + console.debug("[worker] initializing WASM") 27 + await init() 28 + console.debug("[worker] ready, binding check:", bindingCheck()) 23 29 24 30 const cryptoApi = { 25 31 ping(): string { 26 - return "pong"; 32 + return "pong" 27 33 }, 28 34 29 35 bindingCheck(): string { 30 - return bindingCheck(); 36 + return bindingCheck() 31 37 }, 32 38 33 39 generateContentKey(): Uint8Array { 34 - return generateContentKey(); 40 + return generateContentKey() 35 41 }, 36 42 37 43 encryptBlob(key: Uint8Array, plaintext: Uint8Array): EncryptedPayload { 38 - return encryptBlob(key, plaintext) as EncryptedPayload; 44 + return encryptBlob(key, plaintext) as EncryptedPayload 39 45 }, 40 46 41 - decryptBlob( 42 - key: Uint8Array, 43 - ciphertext: Uint8Array, 44 - nonce: Uint8Array, 45 - ): Uint8Array { 46 - return decryptBlob(key, ciphertext, nonce); 47 + decryptBlob(key: Uint8Array, ciphertext: Uint8Array, nonce: Uint8Array): Uint8Array { 48 + return decryptBlob(key, ciphertext, nonce) 47 49 }, 48 50 49 - wrapKey( 50 - contentKey: Uint8Array, 51 - recipientPubKey: Uint8Array, 52 - recipientDid: string, 53 - ): WrappedKey { 54 - return wrapKey(contentKey, recipientPubKey, recipientDid) as WrappedKey; 51 + wrapKey(contentKey: Uint8Array, recipientPubKey: Uint8Array, recipientDid: string): WrappedKey { 52 + return wrapKey(contentKey, recipientPubKey, recipientDid) as WrappedKey 55 53 }, 56 54 57 55 unwrapKey(wrappedKey: WrappedKey, privateKey: Uint8Array): Uint8Array { 58 - return unwrapKey(wrappedKey, privateKey); 56 + return unwrapKey(wrappedKey, privateKey) 59 57 }, 60 58 61 - wrapContentKeyForKeyring( 62 - contentKey: Uint8Array, 63 - groupKey: Uint8Array, 64 - ): Uint8Array { 65 - return wrapContentKeyForKeyring(contentKey, groupKey); 59 + wrapContentKeyForKeyring(contentKey: Uint8Array, groupKey: Uint8Array): Uint8Array { 60 + return wrapContentKeyForKeyring(contentKey, groupKey) 66 61 }, 67 62 68 - unwrapContentKeyFromKeyring( 69 - wrapped: Uint8Array, 70 - groupKey: Uint8Array, 71 - ): Uint8Array { 72 - return unwrapContentKeyFromKeyring(wrapped, groupKey); 63 + unwrapContentKeyFromKeyring(wrapped: Uint8Array, groupKey: Uint8Array): Uint8Array { 64 + return unwrapContentKeyFromKeyring(wrapped, groupKey) 73 65 }, 74 66 75 67 // OAuth / DPoP 76 68 77 69 generateDpopKeyPair(): DpopKeyPair { 78 - return wasmGenerateDpopKeyPair() as DpopKeyPair; 70 + return wasmGenerateDpopKeyPair() as DpopKeyPair 79 71 }, 80 72 81 73 createDpopProof( ··· 93 85 timestamp, 94 86 nonce ?? undefined, 95 87 accessToken ?? undefined, 96 - ); 88 + ) 97 89 }, 98 90 99 91 generatePkce(): PkceChallenge { 100 - return wasmGeneratePkce() as PkceChallenge; 92 + return wasmGeneratePkce() as PkceChallenge 101 93 }, 102 94 103 95 generateIdentity(did: string): Identity { 104 - return wasmGenerateIdentity(did) as Identity; 96 + return wasmGenerateIdentity(did) as Identity 105 97 }, 106 98 107 99 generateEphemeralKeypair(): EphemeralKeypair { 108 - return wasmGenerateEphemeralKeypair() as EphemeralKeypair; 100 + return wasmGenerateEphemeralKeypair() as EphemeralKeypair 109 101 }, 110 - }; 102 + } 111 103 112 - export type CryptoApi = typeof cryptoApi; 104 + export type CryptoApi = typeof cryptoApi 113 105 114 - Comlink.expose(cryptoApi); 106 + Comlink.expose(cryptoApi)
+1 -1
web/tsconfig.node.json
··· 9 9 "skipLibCheck": true, 10 10 "isolatedModules": true 11 11 }, 12 - "include": ["vite.config.ts"] 12 + "include": ["vite.config.ts", "eslint.config.ts"] 13 13 }