Merge branch 'deep-ketch' into development
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
CI/CD Pipeline / i18n Catalog Check (push) Successful in 38s
CI/CD Pipeline / Build and Push App Docker Image (push) Successful in 3m11s
CI/CD Pipeline / Build and Push PocketBase Docker Image (push) Successful in 14s
CI/CD Pipeline / Deploy to Kubernetes (push) Successful in 8m31s
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1",
|
||||
},
|
||||
},
|
||||
@@ -499,6 +500,8 @@
|
||||
|
||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@tanstack/devtools": ["@tanstack/devtools@0.7.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.3", "@tanstack/devtools-event-bus": "0.3.3", "@tanstack/devtools-ui": "0.4.4", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-AlAoCqJhWLg9GBEaoV1g/j+X/WA1aJSWOsekxeuZpYeS2hdVuKAjj04KQLUMJhtLfNl2s2E+TCj7ZRtWyY3U4w=="],
|
||||
|
||||
"@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.3", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.3.3" } }, "sha512-kl0r6N5iIL3t9gGDRAv55VRM3UIyMKVH83esRGq7xBjYsRLe/BeCIN2HqrlJkObUXQMKhy7i8ejuGOn+bDqDBw=="],
|
||||
@@ -631,6 +634,10 @@
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="],
|
||||
|
||||
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
|
||||
|
||||
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/istanbul-lib-coverage": ["@types/istanbul-lib-coverage@2.0.6", "", {}, "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w=="],
|
||||
@@ -665,6 +672,20 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.3", "", { "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.2", "@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-NVUnA6gQCl8jfoYqKqQU5Clv0aPw14KkZYCsX6T9Lfu9slI0LOU10OTwFHS/WmptsMMpshNd/1tuWsHQ2Uk+cg=="],
|
||||
|
||||
"@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="],
|
||||
|
||||
"@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="],
|
||||
|
||||
"@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="],
|
||||
|
||||
"@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="],
|
||||
|
||||
"@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="],
|
||||
|
||||
"@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="],
|
||||
|
||||
"@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="],
|
||||
|
||||
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
|
||||
|
||||
"agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
|
||||
@@ -687,6 +708,8 @@
|
||||
|
||||
"asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
|
||||
|
||||
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
|
||||
@@ -749,6 +772,8 @@
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
@@ -855,6 +880,8 @@
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -867,12 +894,14 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||
|
||||
"eta": ["eta@4.6.0", "", {}, "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA=="],
|
||||
|
||||
"expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
"facehash": ["facehash@0.0.7", "", { "peerDependencies": { "@types/react": "", "next": ">=15", "react": ">=18 <20", "react-dom": ">=18 <20" }, "optionalPeers": ["@types/react", "next"] }, "sha512-P4fw6z5DIGMbjtqEaOw7fYvYpQetSOSJOfqy3xuET7cDUI6f9CKlSX0UZIYNrtsPpCoz3LoPP5E8bNbpZBP30A=="],
|
||||
@@ -1189,6 +1218,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="],
|
||||
|
||||
"onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
|
||||
|
||||
"ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="],
|
||||
@@ -1397,6 +1428,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"smob": ["smob@1.6.2", "", {}, "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw=="],
|
||||
@@ -1413,6 +1446,10 @@
|
||||
|
||||
"srvx": ["srvx@0.11.22", "", { "bin": { "srvx": "bin/srvx.mjs" } }, "sha512-LqZxxBDMKuMAZzFzJnDCkFOrs9MZQZr0LvHiO/SuSZVdQaXD7xQ5UWTUxheJrQPve1qk9MG2B/yttUvJxw8egQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
|
||||
|
||||
"stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="],
|
||||
|
||||
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
|
||||
@@ -1457,10 +1494,16 @@
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="],
|
||||
|
||||
"tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="],
|
||||
|
||||
"tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
|
||||
|
||||
"tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
|
||||
@@ -1541,6 +1584,8 @@
|
||||
|
||||
"vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
|
||||
|
||||
"vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="],
|
||||
|
||||
"w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="],
|
||||
|
||||
"web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
|
||||
@@ -1561,6 +1606,8 @@
|
||||
|
||||
"which-typed-array": ["which-typed-array@1.1.22", "", { "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.9", "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-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"workbox-background-sync": ["workbox-background-sync@7.4.1", "", { "dependencies": { "idb": "^7.0.1", "workbox-core": "7.4.1" } }, "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g=="],
|
||||
|
||||
"workbox-broadcast-update": ["workbox-broadcast-update@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A=="],
|
||||
@@ -1817,6 +1864,8 @@
|
||||
|
||||
"@jest/types/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@rollup/pluginutils/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"@tanstack/router-generator/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
@@ -1907,6 +1956,8 @@
|
||||
|
||||
"unplugin/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"vitest/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="],
|
||||
|
||||
"web-push/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"workbox-build/source-map": ["source-map@0.8.0-beta.0", "", { "dependencies": { "whatwg-url": "^7.0.0" } }, "sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA=="],
|
||||
|
||||
+3
-1
@@ -12,7 +12,8 @@
|
||||
"extract": "lingui extract --clean",
|
||||
"i18n:check": "lingui extract --clean && lingui compile --strict",
|
||||
"build": "lingui extract --clean && vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
|
||||
"start": "bun run server.ts"
|
||||
"start": "bun run server.ts",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
@@ -73,6 +74,7 @@
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^4.1.11",
|
||||
"workbox-build": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,13 @@ import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
|
||||
import { HeaderConfig } from "@/features/core/types/header-config";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
||||
import { DEFAULT_LOCALE, ensureMessages, resolveLocale } from "@/lib/i18n";
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
ensureMessages,
|
||||
getCachedMessages,
|
||||
i18nMessagesQueryKey,
|
||||
resolveLocale,
|
||||
} from "@/lib/i18n";
|
||||
import { getRootMeta } from "@/lib/i18n/meta";
|
||||
import FullScreenLoader from "@/components/full-screen-loader";
|
||||
import { CHROME_COLORS } from "@/lib/mantine/theme-colors";
|
||||
@@ -132,7 +138,14 @@ export const Route = createRootRouteWithContext<{
|
||||
context.queryClient,
|
||||
playerQueries.auth()
|
||||
);
|
||||
await ensureMessages(resolveLocale(auth?.metadata?.locale));
|
||||
const locale = resolveLocale(auth?.metadata?.locale);
|
||||
await ensureMessages(locale);
|
||||
if (typeof window === "undefined" && locale !== DEFAULT_LOCALE) {
|
||||
context.queryClient.setQueryData(
|
||||
i18nMessagesQueryKey(locale),
|
||||
getCachedMessages(locale)
|
||||
);
|
||||
}
|
||||
return { auth };
|
||||
} catch (error: any) {
|
||||
if (isRedirect(error) || error instanceof Response) throw error;
|
||||
|
||||
@@ -2,9 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
import { Box, Paper, Group, Text, ActionIcon } from '@mantine/core'
|
||||
import { DownloadIcon, XIcon } from '@phosphor-icons/react'
|
||||
import { Trans, useLingui } from '@lingui/react/macro'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { useIsMobile } from '@/hooks/use-is-mobile'
|
||||
|
||||
const ABOVE_MOBILE_NAV_OFFSET = 'calc(4.5rem + env(safe-area-inset-bottom, 0px))'
|
||||
|
||||
export function IOSInstallPrompt() {
|
||||
const { t } = useLingui()
|
||||
const { user } = useAuth()
|
||||
const isMobile = useIsMobile()
|
||||
const [show, setShow] = useState(false)
|
||||
const [platform, setPlatform] = useState<'ios' | 'android' | null>(null)
|
||||
|
||||
@@ -36,22 +42,27 @@ export function IOSInstallPrompt() {
|
||||
? t`Tap Share → Add to Home Screen`
|
||||
: t`Tap Menu (⋮) → Add to Home screen`
|
||||
|
||||
const aboveBottomNav = Boolean(user) && isMobile
|
||||
const bottomStyle = aboveBottomNav
|
||||
? { bottom: ABOVE_MOBILE_NAV_OFFSET, paddingBottom: '8px' }
|
||||
: { bottom: 0, paddingBottom: 'calc(8px + env(safe-area-inset-bottom, 0px))' }
|
||||
|
||||
return (
|
||||
<Box style={{ position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 1000, padding: '8px' }}>
|
||||
<Box style={{ position: 'fixed', left: 0, right: 0, zIndex: 1000, padding: '8px', ...bottomStyle }}>
|
||||
<Paper shadow="lg" p="sm" style={{ background: 'var(--mantine-color-blue-9)', color: 'white' }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ flex: 1 }}>
|
||||
<DownloadIcon size={20} style={{ flexShrink: 0 }} />
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} style={{ lineHeight: 1.3 }}>
|
||||
<Trans>Please install FLXN • This will save me Twilio credits as you won't be signed out!</Trans>
|
||||
<Trans>Add FLXN to your home screen for a smoother experience</Trans>
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9} style={{ lineHeight: 1.2 }}>
|
||||
{instructions}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
<ActionIcon variant="subtle" color="white" onClick={handleDismiss}>
|
||||
<ActionIcon variant="subtle" color="white" onClick={handleDismiss} aria-label={t`Dismiss`}>
|
||||
<XIcon size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
import { superTokensAdminFunctionMiddleware, superTokensFunctionMiddleware } from "@/utils/supertokens";
|
||||
import { serverFnLoggingMiddleware } from "@/utils/activities";
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
@@ -14,7 +15,7 @@ export const getPlayerBadges = createServerFn()
|
||||
);
|
||||
|
||||
export const migrateBadgeProgress = createServerFn()
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(async () => startBadgeMigration())
|
||||
);
|
||||
@@ -40,7 +41,7 @@ export const awardManualBadge = createServerFn()
|
||||
playerId: z.string(),
|
||||
badgeId: z.string(),
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const result = await pbAdmin.awardManualBadge(data.playerId, data.badgeId);
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { Title, AppShell, Flex } from "@mantine/core";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { HeaderConfig } from "../types/header-config";
|
||||
import BackButton from "./back-button";
|
||||
|
||||
const MIN_TITLE_SCALE = 0.55;
|
||||
|
||||
const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig) => {
|
||||
const { i18n } = useLingui();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const resolvedTitle =
|
||||
typeof title === "string" || title === undefined
|
||||
? title
|
||||
: i18n._({ ...title, values: titleValues });
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = titleRef.current;
|
||||
if (!el) return;
|
||||
let cancelled = false;
|
||||
|
||||
const textWidth = () => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
return range.getBoundingClientRect().width;
|
||||
};
|
||||
|
||||
const fit = () => {
|
||||
el.style.fontSize = "";
|
||||
const available = el.clientWidth - 1;
|
||||
if (available <= 0) return;
|
||||
let needed = textWidth();
|
||||
if (needed <= available) return;
|
||||
const natural = parseFloat(getComputedStyle(el).fontSize);
|
||||
let scale = Math.max(available / needed, MIN_TITLE_SCALE);
|
||||
el.style.fontSize = `${natural * scale}px`;
|
||||
for (let i = 0; i < 3 && scale > MIN_TITLE_SCALE && textWidth() > available; i++) {
|
||||
scale = Math.max(scale * 0.98, MIN_TITLE_SCALE);
|
||||
el.style.fontSize = `${natural * scale}px`;
|
||||
}
|
||||
};
|
||||
|
||||
fit();
|
||||
document.fonts?.ready.then(() => {
|
||||
if (!cancelled) fit();
|
||||
});
|
||||
const observer = new ResizeObserver(fit);
|
||||
observer.observe(el);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [resolvedTitle]);
|
||||
|
||||
return (
|
||||
<AppShell.Header
|
||||
id='app-header'
|
||||
@@ -20,8 +62,20 @@ const Header = ({ collapsed, title, titleValues, withBackButton }: HeaderConfig)
|
||||
}}
|
||||
>
|
||||
{ withBackButton && <BackButton /> }
|
||||
<Flex justify='center' px='md' mt={8}>
|
||||
<Title order={1} lts='0.08em' style={{ userSelect: 'none' }}>
|
||||
<Flex justify='center' px={48} mt={8} w='100%'>
|
||||
<Title
|
||||
ref={titleRef}
|
||||
order={1}
|
||||
lts='0.08em'
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
width: '100%',
|
||||
textAlign: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{resolvedTitle?.toLocaleUpperCase()}
|
||||
</Title>
|
||||
</Flex>
|
||||
|
||||
@@ -5,11 +5,10 @@ const eventListerOptions = {
|
||||
};
|
||||
|
||||
const useVisualViewportSize = () => {
|
||||
const windowExists = typeof window !== 'undefined';
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: windowExists ? window.visualViewport?.width || 0 : 0,
|
||||
height: windowExists ? window.visualViewport?.height || 0 : 0,
|
||||
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
});
|
||||
const rafRef = useRef<number | null>(null);
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export const updatePlayer = createServerFn()
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
const userAuthId = context.userAuthId;
|
||||
|
||||
@@ -148,7 +148,7 @@ export const generateRandomTeams = createServerFn()
|
||||
tournamentId: z.string(),
|
||||
seed: z.number().optional()
|
||||
}))
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
const freeAgents = await pbAdmin.getFreeAgents(data.tournamentId);
|
||||
|
||||
+18
-3
@@ -32,6 +32,8 @@ export function resolveLocale(candidate: string | null | undefined): AppLocale {
|
||||
: DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
const inflightLoads: Partial<Record<AppLocale, Promise<Messages>>> = {};
|
||||
|
||||
export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||
const cached = messageCache[locale];
|
||||
if (cached) return cached;
|
||||
@@ -39,11 +41,24 @@ export async function ensureMessages(locale: AppLocale): Promise<Messages> {
|
||||
const loader = catalogLoaders[`/src/locales/${locale}/messages.po`];
|
||||
if (!loader) return messageCache[DEFAULT_LOCALE]!;
|
||||
|
||||
const { messages } = await loader();
|
||||
messageCache[locale] = messages;
|
||||
return messages;
|
||||
const pending = (inflightLoads[locale] ??= loader()
|
||||
.then(({ messages }) => {
|
||||
messageCache[locale] = messages;
|
||||
return messages;
|
||||
})
|
||||
.finally(() => {
|
||||
delete inflightLoads[locale];
|
||||
}));
|
||||
return pending;
|
||||
}
|
||||
|
||||
export function primeMessages(locale: AppLocale, messages: Messages) {
|
||||
messageCache[locale] ??= messages;
|
||||
}
|
||||
|
||||
export const i18nMessagesQueryKey = (locale: AppLocale) =>
|
||||
["i18n-messages", locale] as const;
|
||||
|
||||
export function getCachedMessages(locale: AppLocale): Messages {
|
||||
return messageCache[locale] ?? messageCache[DEFAULT_LOCALE]!;
|
||||
}
|
||||
|
||||
+28
-10
@@ -1,11 +1,14 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import type { Messages } from "@lingui/core";
|
||||
import { playerQueries } from "@/features/players/queries";
|
||||
import {
|
||||
createI18n,
|
||||
ensureMessages,
|
||||
hasCachedMessages,
|
||||
i18nMessagesQueryKey,
|
||||
primeMessages,
|
||||
resolveLocale,
|
||||
} from "@/lib/i18n";
|
||||
|
||||
@@ -29,27 +32,42 @@ export const LinguiProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const locale = resolveLocale(data?.metadata?.locale);
|
||||
const [catalogVersion, setCatalogVersion] = useState(0);
|
||||
|
||||
// Fresh instance per locale (and per SSR request tree). __root beforeLoad
|
||||
// preloads the catalog on authed routes; on public routes (login) it may
|
||||
// not be cached yet — createI18n falls back to en until the effect below
|
||||
// loads it and bumps catalogVersion.
|
||||
const i18n = useMemo(
|
||||
() => createI18n(locale),
|
||||
() => {
|
||||
if (!hasCachedMessages(locale)) {
|
||||
const seeded = queryClient.getQueryData<Messages>(
|
||||
i18nMessagesQueryKey(locale)
|
||||
);
|
||||
if (seeded) primeMessages(locale, seeded);
|
||||
}
|
||||
return createI18n(locale);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[locale, catalogVersion]
|
||||
[locale, catalogVersion, queryClient]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasCachedMessages(locale)) return;
|
||||
let cancelled = false;
|
||||
ensureMessages(locale).then(() => {
|
||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||
});
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const load = () => {
|
||||
ensureMessages(locale).then(
|
||||
() => {
|
||||
if (!cancelled) setCatalogVersion((v) => v + 1);
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) retryTimer = setTimeout(load, 3000);
|
||||
}
|
||||
);
|
||||
};
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimer) clearTimeout(retryTimer);
|
||||
};
|
||||
}, [locale]);
|
||||
|
||||
|
||||
@@ -19,15 +19,30 @@ class PocketBaseAdminClient {
|
||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||
|
||||
this.pb.beforeSend = async (url, options) => {
|
||||
await this.authPromise;
|
||||
|
||||
if (this.pb.authStore.isValid && this.isTokenExpiringSoon()) {
|
||||
// Auth requests skip the gate: awaiting authPromise here would deadlock them.
|
||||
if (!url.includes("/collections/_superusers/auth-")) {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||
await this.authenticate();
|
||||
await this.authPromise;
|
||||
} catch {}
|
||||
|
||||
if (!this.pb.authStore.isValid) {
|
||||
this.authPromise = this.authenticate();
|
||||
await this.authPromise;
|
||||
this.startTokenRefresh();
|
||||
} else if (this.isTokenExpiringSoon()) {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh admin token, re-authenticating:', error);
|
||||
this.authPromise = this.authenticate();
|
||||
await this.authPromise;
|
||||
}
|
||||
}
|
||||
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
Authorization: this.pb.authStore.token,
|
||||
};
|
||||
}
|
||||
|
||||
options.cache = "no-store";
|
||||
@@ -54,9 +69,16 @@ class PocketBaseAdminClient {
|
||||
Object.assign(this, createPushService(this.pb));
|
||||
|
||||
this.authPromise = this.authenticate();
|
||||
this.authPromise.then(() => {
|
||||
this.startTokenRefresh();
|
||||
});
|
||||
this.authPromise
|
||||
.then(() => {
|
||||
this.startTokenRefresh();
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"PocketBase admin authentication failed - is PocketBase running and are " +
|
||||
"POCKETBASE_URL / POCKETBASE_ADMIN_EMAIL / POCKETBASE_ADMIN_PASSWORD set?"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async authenticate() {
|
||||
@@ -98,6 +120,8 @@ class PocketBaseAdminClient {
|
||||
}
|
||||
|
||||
private startTokenRefresh() {
|
||||
if (this.refreshInterval) return;
|
||||
|
||||
this.refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
await this.refreshAuth();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { PlayerInfo } from "@/features/players/types";
|
||||
import { pbFilter } from "../util/filter";
|
||||
import { likePattern } from "../util/like-pattern";
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
@@ -61,15 +63,15 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
const filters: string[] = [];
|
||||
|
||||
if (name) {
|
||||
filters.push(`name ~ "${name}"`);
|
||||
filters.push(pbFilter(pb, "name ~ {:name}", { name: likePattern(name) }));
|
||||
}
|
||||
|
||||
if (player) {
|
||||
filters.push(`player = "${player}"`);
|
||||
filters.push(pbFilter(pb, "player = {:player}", { player }));
|
||||
}
|
||||
|
||||
if (success !== undefined) {
|
||||
filters.push(`success = ${success}`);
|
||||
filters.push(pbFilter(pb, "success = {:success}", { success }));
|
||||
}
|
||||
|
||||
const filterString = filters.length > 0 ? filters.join(" && ") : "";
|
||||
@@ -98,7 +100,7 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
|
||||
async getActivitiesByUser(userId: string, limit: number = 50): Promise<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `player = "${userId}"`,
|
||||
filter: pbFilter(pb, "player = {:userId}", { userId }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
@@ -106,7 +108,7 @@ export function createActivitiesService(pb: PocketBase) {
|
||||
|
||||
async getActivitiesByFunction(functionName: string, limit: number = 50): Promise<Activity[]> {
|
||||
const result = await pb.collection("activities").getList<Activity>(1, limit, {
|
||||
filter: `name = "${functionName}"`,
|
||||
filter: pbFilter(pb, "name = {:functionName}", { functionName }),
|
||||
sort: "-created",
|
||||
});
|
||||
return result.items;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pbFilter } from "./filter";
|
||||
|
||||
// Real SDK, not a stub: the escaping under test lives in PocketBase's own
|
||||
// replaceAll.
|
||||
const pb = new PocketBase("http://pocketbase.test");
|
||||
|
||||
const quotedLiteral = (expression: string) => {
|
||||
const open = expression.indexOf("'");
|
||||
let literal = "";
|
||||
for (let i = open + 1; i < expression.length; i++) {
|
||||
if (expression[i] === "\\") {
|
||||
literal += expression[i + 1];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (expression[i] === "'") return literal;
|
||||
literal += expression[i];
|
||||
}
|
||||
throw new Error(`unterminated literal in ${expression}`);
|
||||
};
|
||||
|
||||
describe("pbFilter", () => {
|
||||
it.each([
|
||||
["a match-substitution pattern", "a$&b"],
|
||||
["a preceding-input pattern", "a$`b"],
|
||||
["a following-input pattern", "a$'b"],
|
||||
["an escaped-dollar pattern", "a$$b"],
|
||||
["a bare dollar", "a$b"],
|
||||
["nothing but a dollar", "$"],
|
||||
["a dollar beside a metacharacter", "50$%"],
|
||||
])("substitutes %s as a literal", (_label, term) => {
|
||||
const expression = pbFilter(pb, "first_name ~ {:query}", { query: term });
|
||||
|
||||
expect(quotedLiteral(expression)).toBe(term);
|
||||
expect(expression).not.toContain("{:query}");
|
||||
});
|
||||
|
||||
it("keeps both dollars of an escaped-dollar pattern", () => {
|
||||
expect(pbFilter(pb, "f ~ {:q}", { q: "a$$b" })).toBe("f ~ 'a$$b'");
|
||||
});
|
||||
|
||||
it("splices no part of the surrounding expression into the literal", () => {
|
||||
const expression = pbFilter(pb, "first_name ~ {:query}", {
|
||||
query: "a$`b",
|
||||
});
|
||||
|
||||
expect(expression).toBe("first_name ~ 'a$`b'");
|
||||
expect(quotedLiteral(expression)).not.toContain("first_name");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a single quote", "o'brien"],
|
||||
["a trailing backslash", "ada\\"],
|
||||
["a percent sign", "50%"],
|
||||
])("escapes %s exactly as the SDK does", (_label, term) => {
|
||||
expect(pbFilter(pb, "f ~ {:q}", { q: term })).toBe(
|
||||
pb.filter("f ~ {:q}", { q: term })
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a number", 42],
|
||||
["a boolean", true],
|
||||
["null", null],
|
||||
["a date", new Date(Date.UTC(2024, 0, 2, 3, 4, 5, 678))],
|
||||
])("renders %s identically to pb.filter", (_label, value) => {
|
||||
expect(pbFilter(pb, "f = {:v}", { v: value })).toBe(
|
||||
pb.filter("f = {:v}", { v: value })
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a placeholder with no matching parameter verbatim", () => {
|
||||
expect(pbFilter(pb, "a = {:missing}", {})).toBe("a = {:missing}");
|
||||
expect(pbFilter(pb, "a = {:missing}", {})).toBe(
|
||||
pb.filter("a = {:missing}", {})
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a hyphen", "user-id"],
|
||||
["a dot", "meta.user"],
|
||||
["a digit and underscore", "auth_id2"],
|
||||
])("resolves a key containing %s the way the SDK does", (_label, key) => {
|
||||
const raw = `a = {:${key}}`;
|
||||
|
||||
expect(pbFilter(pb, raw, { [key]: "v" })).toBe("a = 'v'");
|
||||
expect(pbFilter(pb, raw, { [key]: "v" })).toBe(pb.filter(raw, { [key]: "v" }));
|
||||
});
|
||||
|
||||
it("does not absorb another parameter's value into a literal", () => {
|
||||
expect(
|
||||
pbFilter(pb, "a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })
|
||||
).toBe("a = '%{:y}%' && b = 'SECRET'");
|
||||
});
|
||||
|
||||
it("substitutes every occurrence of a repeated placeholder", () => {
|
||||
expect(
|
||||
pbFilter(pb, "(first_name ~ {:q} || last_name ~ {:q})", { q: "%ada%" })
|
||||
).toBe("(first_name ~ '%ada%' || last_name ~ '%ada%')");
|
||||
});
|
||||
});
|
||||
|
||||
// Pins the SDK behaviour the doubling in `quote` compensates for. If an upgrade
|
||||
// fixes the expansion upstream, these fail rather than the doubling silently
|
||||
// becoming a double-escape.
|
||||
describe("the pocketbase SDK substitution this works around", () => {
|
||||
it("still mis-expands a dollar pattern in a parameter value", () => {
|
||||
expect(pb.filter("a ~ {:q}", { q: "x$&y" })).toContain("{:q}");
|
||||
});
|
||||
|
||||
it("still splices a later parameter into an earlier literal", () => {
|
||||
expect(pb.filter("a = {:x} && b = {:y}", { x: "%{:y}%", y: "SECRET" })).toBe(
|
||||
"a = '%'SECRET'%' && b = 'SECRET'"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type PocketBase from "pocketbase";
|
||||
|
||||
export type FilterParam = string | number | boolean | Date | null;
|
||||
|
||||
const PLACEHOLDER = /\{:([^}]+)\}/g;
|
||||
|
||||
// pb.filter $-expands string replacements; doubling `$` keeps the value literal.
|
||||
const quote = (pb: PocketBase, value: FilterParam) =>
|
||||
pb.filter("{:v}", {
|
||||
v: typeof value === "string" ? value.replaceAll("$", () => "$$") : value,
|
||||
});
|
||||
|
||||
export const pbFilter = (
|
||||
pb: PocketBase,
|
||||
raw: string,
|
||||
params: Record<string, FilterParam>
|
||||
) =>
|
||||
raw.replace(PLACEHOLDER, (token, key: string) =>
|
||||
Object.hasOwn(params, key) ? quote(pb, params[key]) : token
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
import PocketBase from "pocketbase";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { literalTerminates } from "@/test/pb-filter";
|
||||
import { pbFilter } from "./filter";
|
||||
import { likePattern } from "./like-pattern";
|
||||
|
||||
const compose = (term: string) =>
|
||||
pbFilter(new PocketBase("http://pocketbase.test"), "first_name ~ {:query}", {
|
||||
query: likePattern(term),
|
||||
});
|
||||
|
||||
describe("likePattern", () => {
|
||||
it("wraps a plain term so ~ matches a substring", () => {
|
||||
expect(likePattern("ada")).toBe("%ada%");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a percent sign", "50%", "%50\\%%"],
|
||||
["an underscore", "_", "%\\_%"],
|
||||
["a backslash", "ada\\", "%ada\\\\%"],
|
||||
])("escapes %s so it matches literally", (_label, term, expected) => {
|
||||
expect(likePattern(term)).toBe(expected);
|
||||
});
|
||||
|
||||
it("always ends the operand with an unescaped wildcard", () => {
|
||||
// An odd run of backslashes before the final % would mean the % is itself
|
||||
// escaped, which is the shape that swallows the closing quote.
|
||||
for (const term of ["ada", "ada\\", "\\", "50%", "_", "o'brien\\"]) {
|
||||
const pattern = likePattern(term);
|
||||
const trailingSlashes = /(\\*)%$/.exec(pattern)?.[1] ?? "";
|
||||
|
||||
expect(pattern.endsWith("%")).toBe(true);
|
||||
expect(trailingSlashes.length % 2).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("likePattern composed through the real SDK", () => {
|
||||
it.each(["ada\\", "\\", "a_b\\", "o'brien\\", "ada\\\\"])(
|
||||
"keeps the filter expression parseable for %j",
|
||||
(term) => {
|
||||
expect(literalTerminates(compose(term))).toBe(true);
|
||||
}
|
||||
);
|
||||
|
||||
it("detects the unterminated literal a raw term produces", () => {
|
||||
const raw = new PocketBase("http://pocketbase.test").filter(
|
||||
"first_name ~ {:query}",
|
||||
{ query: "ada\\" }
|
||||
);
|
||||
|
||||
expect(raw).toBe("first_name ~ 'ada\\'");
|
||||
expect(literalTerminates(raw)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a term without metacharacters exactly as before", () => {
|
||||
expect(compose("ada")).toBe("first_name ~ '%ada%'");
|
||||
});
|
||||
|
||||
it.each(["a$&b", "a$`b", "50$%", "a$&_b", "$"])(
|
||||
"carries %j through both escaping layers intact",
|
||||
(term) => {
|
||||
const composed = compose(term);
|
||||
|
||||
expect(composed).toContain(likePattern(term));
|
||||
expect(literalTerminates(composed)).toBe(true);
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
// Escapes LIKE metacharacters and wraps in `%` for a literal substring match.
|
||||
export const likePattern = (term: string) =>
|
||||
`%${term.replace(/[\\%_]/g, (char) => `\\${char}`)}%`;
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { redactValue, SENSITIVE_KEY } from "./redact";
|
||||
|
||||
// Shared scrubber for logs + audit rows - a regression leaks PII from both.
|
||||
describe("redactValue", () => {
|
||||
it("redacts sensitive keys and preserves benign ones", () => {
|
||||
expect(
|
||||
redactValue({ phone: "+17135550142", first_name: "Ada", token: "abc" })
|
||||
).toEqual({ phone: "[redacted]", first_name: "Ada", token: "[redacted]" });
|
||||
});
|
||||
|
||||
it("redacts recursively through nested objects and arrays", () => {
|
||||
expect(
|
||||
redactValue({
|
||||
user: { first_name: "Ada", password: "hunter2" },
|
||||
items: [{ authToken: "x" }, { label: "ok" }],
|
||||
})
|
||||
).toEqual({
|
||||
user: { first_name: "Ada", password: "[redacted]" },
|
||||
items: [{ authToken: "[redacted]" }, { label: "ok" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes primitives through untouched (a bare string is not assumed secret)", () => {
|
||||
expect(redactValue("hello")).toBe("hello");
|
||||
expect(redactValue(42)).toBe(42);
|
||||
expect(redactValue(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("covers the documented sensitive keys", () => {
|
||||
for (const key of ["token", "secret", "password", "phone", "otp", "code", "auth", "key"]) {
|
||||
expect(SENSITIVE_KEY.test(key)).toBe(true);
|
||||
}
|
||||
expect(SENSITIVE_KEY.test("first_name")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
export const SENSITIVE_KEY = /token|secret|password|phone|otp|code|auth|key/i;
|
||||
|
||||
const REDACTED = "[redacted]";
|
||||
|
||||
export const redactValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(redactValue);
|
||||
if (value && typeof value === "object") {
|
||||
if (value instanceof Error) {
|
||||
return { name: value.name, message: value.message, stack: value.stack };
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[key] = SENSITIVE_KEY.test(key) ? REDACTED : redactValue(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1,6 +1,5 @@
|
||||
import { logger } from "../../logger";
|
||||
import { ErrorType, ServerError, ServerResult } from "../types";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { isRedirect } from "@tanstack/react-router";
|
||||
|
||||
export const createServerError = (
|
||||
@@ -20,54 +19,15 @@ export const createServerError = (
|
||||
export const toServerResult = async <T>(
|
||||
serverFn: () => Promise<T>
|
||||
): Promise<ServerResult<T>> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const data = await serverFn();
|
||||
return { success: true, data };
|
||||
} catch (error) {
|
||||
if (isRedirect(error) || error instanceof Response) throw error;
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
logger.error('Server Fn Error', error);
|
||||
|
||||
const mappedError = mapKnownError(error);
|
||||
|
||||
let fnName = 'unknown';
|
||||
try {
|
||||
const request = getRequest();
|
||||
const url = new URL(request.url);
|
||||
|
||||
const functionId = url.searchParams.get('_serverFnId') || url.pathname;
|
||||
|
||||
if (functionId.includes('--')) {
|
||||
const match = functionId.match(/--([^_]+)_/);
|
||||
fnName = match?.[1] || functionId.split('--')[1]?.split('_')[0] || 'unknown';
|
||||
} else {
|
||||
fnName = serverFn.name || 'unknown';
|
||||
}
|
||||
} catch {
|
||||
fnName = serverFn.name || 'unknown';
|
||||
}
|
||||
|
||||
import("../../pocketbase/client")
|
||||
.then(async ({ pbAdmin }) => {
|
||||
await pbAdmin.authPromise;
|
||||
await pbAdmin.createActivity({
|
||||
name: fnName,
|
||||
duration,
|
||||
success: false,
|
||||
error: mappedError.message,
|
||||
arguments: {
|
||||
errorType: mappedError.code,
|
||||
statusCode: mappedError.statusCode,
|
||||
userMessage: mappedError.userMessage,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { success: false, error: mappedError };
|
||||
return { success: false, error: mapKnownError(error) };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -392,6 +392,10 @@ msgstr "Aktivitäten"
|
||||
msgid "Activity Details"
|
||||
msgstr "Aktivitätsdetails"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:58
|
||||
msgid "Add FLXN to your home screen for a smoother experience"
|
||||
msgstr "Leg FLXN auf deinen Homescreen für ein flüssigeres Erlebnis"
|
||||
|
||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||
msgid "Add Player"
|
||||
msgstr "Spieler hinzufügen"
|
||||
@@ -853,6 +857,10 @@ msgstr "deviceId ist für die Übertragungsaktion erforderlich"
|
||||
msgid "Disconnect Spotify"
|
||||
msgstr "Spotify trennen"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:65
|
||||
msgid "Dismiss"
|
||||
msgstr "Schließen"
|
||||
|
||||
#: src/features/bracket/components/match-report.tsx:159
|
||||
msgid "Does this look right?"
|
||||
msgstr "Sieht das richtig aus?"
|
||||
@@ -1885,10 +1893,6 @@ msgstr "Spieler sind erforderlich"
|
||||
msgid "Playing on {0}"
|
||||
msgstr "Wird auf {0} abgespielt"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:47
|
||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
||||
msgstr "Installier FLXN • Das spart mir Twilio-Credits, weil du nicht ausgeloggt wirst!"
|
||||
|
||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||
msgid "Populate Knockout Bracket"
|
||||
msgstr "K.-o.-Runde befüllen"
|
||||
@@ -2347,11 +2351,11 @@ msgstr "T"
|
||||
msgid "Tap an opponent below to compare"
|
||||
msgstr "Tippe unten auf einen Gegner zum Vergleichen"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:37
|
||||
#: src/components/ios-install-prompt.tsx:43
|
||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||
msgstr "Tippe auf Menü (⋮) → Zum Home-Bildschirm hinzufügen"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:36
|
||||
#: src/components/ios-install-prompt.tsx:42
|
||||
msgid "Tap Share → Add to Home Screen"
|
||||
msgstr "Tippe auf Teilen → Zum Home-Bildschirm hinzufügen"
|
||||
|
||||
|
||||
@@ -392,6 +392,10 @@ msgstr "Activities"
|
||||
msgid "Activity Details"
|
||||
msgstr "Activity Details"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:58
|
||||
msgid "Add FLXN to your home screen for a smoother experience"
|
||||
msgstr "Add FLXN to your home screen for a smoother experience"
|
||||
|
||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||
msgid "Add Player"
|
||||
msgstr "Add Player"
|
||||
@@ -853,6 +857,10 @@ msgstr "deviceId is required for transfer action"
|
||||
msgid "Disconnect Spotify"
|
||||
msgstr "Disconnect Spotify"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:65
|
||||
msgid "Dismiss"
|
||||
msgstr "Dismiss"
|
||||
|
||||
#: src/features/bracket/components/match-report.tsx:159
|
||||
msgid "Does this look right?"
|
||||
msgstr "Does this look right?"
|
||||
@@ -1885,10 +1893,6 @@ msgstr "Players are required"
|
||||
msgid "Playing on {0}"
|
||||
msgstr "Playing on {0}"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:47
|
||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
||||
msgstr "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
||||
|
||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||
msgid "Populate Knockout Bracket"
|
||||
msgstr "Populate Knockout Bracket"
|
||||
@@ -2347,11 +2351,11 @@ msgstr "T"
|
||||
msgid "Tap an opponent below to compare"
|
||||
msgstr "Tap an opponent below to compare"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:37
|
||||
#: src/components/ios-install-prompt.tsx:43
|
||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||
msgstr "Tap Menu (⋮) → Add to Home screen"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:36
|
||||
#: src/components/ios-install-prompt.tsx:42
|
||||
msgid "Tap Share → Add to Home Screen"
|
||||
msgstr "Tap Share → Add to Home Screen"
|
||||
|
||||
|
||||
@@ -392,6 +392,10 @@ msgstr "Actividades"
|
||||
msgid "Activity Details"
|
||||
msgstr "Detalles de la actividad"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:58
|
||||
msgid "Add FLXN to your home screen for a smoother experience"
|
||||
msgstr "Agrega FLXN a tu pantalla de inicio para una experiencia más fluida"
|
||||
|
||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||
msgid "Add Player"
|
||||
msgstr "Agregar Jugador"
|
||||
@@ -853,6 +857,10 @@ msgstr "Se requiere deviceId para la acción de transferencia"
|
||||
msgid "Disconnect Spotify"
|
||||
msgstr "Desconectar Spotify"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:65
|
||||
msgid "Dismiss"
|
||||
msgstr "Cerrar"
|
||||
|
||||
#: src/features/bracket/components/match-report.tsx:159
|
||||
msgid "Does this look right?"
|
||||
msgstr "¿Se ve bien así?"
|
||||
@@ -1885,10 +1893,6 @@ msgstr "Los jugadores son obligatorios"
|
||||
msgid "Playing on {0}"
|
||||
msgstr "Reproduciendo en {0}"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:47
|
||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
||||
msgstr "Por favor instala FLXN • Así me ahorras créditos de Twilio porque no se cerrará tu sesión"
|
||||
|
||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||
msgid "Populate Knockout Bracket"
|
||||
msgstr "Llenar el bracket eliminatorio"
|
||||
@@ -2347,11 +2351,11 @@ msgstr "T"
|
||||
msgid "Tap an opponent below to compare"
|
||||
msgstr "Toca un oponente abajo para comparar"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:37
|
||||
#: src/components/ios-install-prompt.tsx:43
|
||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||
msgstr "Toca Menú (⋮) → Agregar a pantalla de inicio"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:36
|
||||
#: src/components/ios-install-prompt.tsx:42
|
||||
msgid "Tap Share → Add to Home Screen"
|
||||
msgstr "Toca Compartir → Agregar a pantalla de inicio"
|
||||
|
||||
|
||||
@@ -392,6 +392,10 @@ msgstr "アクティビティ"
|
||||
msgid "Activity Details"
|
||||
msgstr "アクティビティの詳細"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:58
|
||||
msgid "Add FLXN to your home screen for a smoother experience"
|
||||
msgstr "FLXNをホーム画面に追加すると、もっと快適に使えます"
|
||||
|
||||
#: src/features/tournaments/components/edit-enrolled-players.tsx:117
|
||||
msgid "Add Player"
|
||||
msgstr "プレイヤーを追加"
|
||||
@@ -853,6 +857,10 @@ msgstr "転送操作にはdeviceIdが必要です"
|
||||
msgid "Disconnect Spotify"
|
||||
msgstr "Spotifyの連携を解除"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:65
|
||||
msgid "Dismiss"
|
||||
msgstr "閉じる"
|
||||
|
||||
#: src/features/bracket/components/match-report.tsx:159
|
||||
msgid "Does this look right?"
|
||||
msgstr "これで合っていますか?"
|
||||
@@ -1885,10 +1893,6 @@ msgstr "プレイヤーの選択が必要です"
|
||||
msgid "Playing on {0}"
|
||||
msgstr "{0}で再生中"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:47
|
||||
msgid "Please install FLXN • This will save me Twilio credits as you won't be signed out!"
|
||||
msgstr "FLXNをインストールしてください • ログアウトされなくなるので、Twilioのクレジット節約になります!"
|
||||
|
||||
#: src/features/tournaments/components/group-stage-view.tsx:314
|
||||
msgid "Populate Knockout Bracket"
|
||||
msgstr "ノックアウトブラケットを作成"
|
||||
@@ -2347,11 +2351,11 @@ msgstr "T"
|
||||
msgid "Tap an opponent below to compare"
|
||||
msgstr "下の対戦相手をタップして比較"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:37
|
||||
#: src/components/ios-install-prompt.tsx:43
|
||||
msgid "Tap Menu (⋮) → Add to Home screen"
|
||||
msgstr "メニュー (⋮) をタップ →「ホーム画面に追加」"
|
||||
|
||||
#: src/components/ios-install-prompt.tsx:36
|
||||
#: src/components/ios-install-prompt.tsx:42
|
||||
msgid "Tap Share → Add to Home Screen"
|
||||
msgstr "共有をタップ →「ホーム画面に追加」"
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// Mirrors PocketBase's own scan of a quoted literal: a backslash consumes the
|
||||
// character after it, so an unterminated literal is exactly what a trailing
|
||||
// backslash produces.
|
||||
export const literalTerminates = (expression: string) => {
|
||||
const open = expression.indexOf("'");
|
||||
for (let i = open + 1; i < expression.length; i++) {
|
||||
if (expression[i] === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (expression[i] === "'") return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
request: undefined as { url: string; headers: Headers } | undefined,
|
||||
createActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tanstack/react-start/server", () => ({
|
||||
getRequest: () => h.request,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/pocketbase/client", () => ({
|
||||
pbAdmin: { authPromise: Promise.resolve(), createActivity: h.createActivity },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({
|
||||
Logger: class {
|
||||
error() {}
|
||||
info() {}
|
||||
},
|
||||
}));
|
||||
|
||||
import { recordDeniedServerFn, serverFnLoggingMiddleware } from "./activities";
|
||||
|
||||
type ServerHandler = (opts: {
|
||||
next: () => Promise<unknown>;
|
||||
data: unknown;
|
||||
context: unknown;
|
||||
serverFnMeta?: { id: string; name?: string; filename?: string };
|
||||
}) => Promise<unknown>;
|
||||
|
||||
const runMiddleware = (opts: Parameters<ServerHandler>[0]) =>
|
||||
(serverFnLoggingMiddleware as any).options.server(opts) as ReturnType<ServerHandler>;
|
||||
|
||||
const setRequest = (url: string, userAgent = "vitest") => {
|
||||
h.request = { url, headers: new Headers({ "user-agent": userAgent }) };
|
||||
};
|
||||
|
||||
const successEnvelope = { result: { success: true, data: {} } };
|
||||
|
||||
beforeEach(() => {
|
||||
h.createActivity.mockReset();
|
||||
});
|
||||
|
||||
describe("serverFnLoggingMiddleware", () => {
|
||||
it("records the source name from the compile-time meta, not the hashed url segment", async () => {
|
||||
const hashedId = "a".repeat(64);
|
||||
setRequest(`http://localhost:3000/_serverFn/${hashedId}`);
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: { metadata: { player_id: "p1" } },
|
||||
serverFnMeta: {
|
||||
id: hashedId,
|
||||
name: "updatePlayer",
|
||||
filename: "src/features/players/server.ts",
|
||||
},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||
name: "updatePlayer",
|
||||
player: "p1",
|
||||
success: true,
|
||||
user_agent: "vitest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the last path segment when the meta carries no name", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/deadbeef");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: {},
|
||||
serverFnMeta: { id: "deadbeef" },
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].name).toBe("deadbeef");
|
||||
});
|
||||
|
||||
it("falls back to the last path segment when there is no meta at all", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/legacySegment");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].name).toBe("legacySegment");
|
||||
});
|
||||
|
||||
it("records no player when the session has no player_id", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].player).toBeUndefined();
|
||||
});
|
||||
|
||||
it("falls back to 'unknown' when the path has no trailing segment", async () => {
|
||||
setRequest("http://localhost:3000/");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: undefined,
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].name).toBe("unknown");
|
||||
});
|
||||
|
||||
it("records success:false by reading the returned envelope, not by throwing", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => ({
|
||||
result: {
|
||||
success: false,
|
||||
error: { code: "NOT_FOUND", userMessage: "nope" },
|
||||
},
|
||||
}),
|
||||
data: undefined,
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
const row = h.createActivity.mock.calls[0][0];
|
||||
expect(row.success).toBe(false);
|
||||
expect(row.error).toContain("NOT_FOUND");
|
||||
});
|
||||
|
||||
it("records success:false and rethrows when the handler throws", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await expect(
|
||||
runMiddleware({
|
||||
next: async () => {
|
||||
throw new Error("boom");
|
||||
},
|
||||
data: undefined,
|
||||
context: {},
|
||||
})
|
||||
).rejects.toThrow("boom");
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||
success: false,
|
||||
error: "boom",
|
||||
});
|
||||
});
|
||||
|
||||
it("redacts sensitive argument keys before they reach the audit row", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/login");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: { phone: "+17135550142", first_name: "Ada" },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0].arguments).toEqual({
|
||||
phone: "[redacted]",
|
||||
first_name: "Ada",
|
||||
});
|
||||
});
|
||||
|
||||
it("truncates oversized arguments", async () => {
|
||||
setRequest("http://localhost:3000/_serverFn/doThing");
|
||||
|
||||
await runMiddleware({
|
||||
next: async () => successEnvelope,
|
||||
data: { blob: "x".repeat(5000) },
|
||||
context: {},
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
const args = h.createActivity.mock.calls[0][0].arguments;
|
||||
expect(args.truncated).toBeDefined();
|
||||
expect(args.truncated.length).toBeLessThanOrEqual(2048);
|
||||
});
|
||||
});
|
||||
|
||||
describe("recordDeniedServerFn", () => {
|
||||
const deniedRequest = (url: string) =>
|
||||
({ url, headers: new Headers({ "user-agent": "vitest" }) }) as Request;
|
||||
|
||||
it("names the denial row from the meta so it matches the success rows", async () => {
|
||||
recordDeniedServerFn(
|
||||
deniedRequest(`http://localhost:3000/_serverFn/${"b".repeat(64)}`),
|
||||
{ player_id: "p1" },
|
||||
{ name: "createTournament" }
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||
name: "createTournament",
|
||||
player: "p1",
|
||||
success: false,
|
||||
error: "FORBIDDEN: Access denied",
|
||||
});
|
||||
});
|
||||
|
||||
it("still records a name when the meta is absent", async () => {
|
||||
recordDeniedServerFn(
|
||||
deniedRequest("http://localhost:3000/_serverFn/rawSegment")
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(h.createActivity).toHaveBeenCalledTimes(1));
|
||||
expect(h.createActivity.mock.calls[0][0]).toMatchObject({
|
||||
name: "rawSegment",
|
||||
success: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
+72
-13
@@ -1,6 +1,7 @@
|
||||
import { createMiddleware } from "@tanstack/react-start";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import { redactValue } from "@/lib/redact";
|
||||
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
|
||||
|
||||
const logger = new Logger("Activities");
|
||||
@@ -16,16 +17,61 @@ const recordActivity = (activity: ActivityInput) => {
|
||||
});
|
||||
};
|
||||
|
||||
const MAX_ARGUMENTS_CHARS = 2048;
|
||||
|
||||
const redactArguments = (data: unknown): unknown => {
|
||||
if (data === undefined) return undefined;
|
||||
|
||||
const redacted = redactValue(data);
|
||||
const serialized = JSON.stringify(redacted) ?? "";
|
||||
if (serialized.length > MAX_ARGUMENTS_CHARS) {
|
||||
return { truncated: serialized.slice(0, MAX_ARGUMENTS_CHARS) };
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
|
||||
const auditErrorMessage = (
|
||||
error?: { code?: string; userMessage?: string }
|
||||
): string => {
|
||||
if (!error) return "error";
|
||||
return [error.code, error.userMessage].filter(Boolean).join(": ") || "error";
|
||||
};
|
||||
|
||||
type Actor = { player_id?: string };
|
||||
|
||||
type ServerFnMeta = { name?: string };
|
||||
|
||||
// The URL segment is a hash of the fn id; serverFnMeta carries the readable name.
|
||||
const serverFnName = (request: Request, meta?: ServerFnMeta): string => {
|
||||
if (meta?.name) return meta.name;
|
||||
const pathParts = new URL(request.url).pathname.split("/").filter(Boolean);
|
||||
return pathParts[pathParts.length - 1] || "unknown";
|
||||
};
|
||||
|
||||
export const recordDeniedServerFn = (
|
||||
request: Request,
|
||||
actor?: Actor,
|
||||
meta?: ServerFnMeta
|
||||
) => {
|
||||
recordActivity({
|
||||
name: serverFnName(request, meta),
|
||||
player: actor?.player_id,
|
||||
duration: 0,
|
||||
success: false,
|
||||
error: "FORBIDDEN: Access denied",
|
||||
user_agent: request.headers.get("user-agent") || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
export const serverFnLoggingMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next, data, context }) => {
|
||||
}).server(async ({ next, data, context, serverFnMeta }) => {
|
||||
const request = getRequest();
|
||||
|
||||
const url = new URL(request.url);
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
|
||||
const userId = (context as any)?.metadata?.player_id || 'unknown';
|
||||
const userAgent = request.headers.get('user-agent') || undefined;
|
||||
const name = serverFnName(request, serverFnMeta);
|
||||
const playerId = (context as any)?.metadata?.player_id as string | undefined;
|
||||
const userAgent = request.headers.get("user-agent") || undefined;
|
||||
const safeArgs = redactArguments(data);
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
@@ -33,12 +79,25 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
||||
const result = await next();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
const envelope = (result as { result?: unknown })?.result;
|
||||
const failed =
|
||||
!!envelope &&
|
||||
typeof envelope === "object" &&
|
||||
"success" in envelope &&
|
||||
(envelope as { success: boolean }).success === false;
|
||||
|
||||
recordActivity({
|
||||
name: serverFnName,
|
||||
player: userId !== 'unknown' ? userId : undefined,
|
||||
name,
|
||||
player: playerId,
|
||||
duration,
|
||||
success: true,
|
||||
arguments: data,
|
||||
success: !failed,
|
||||
error: failed
|
||||
? auditErrorMessage(
|
||||
(envelope as { error?: { code?: string; userMessage?: string } })
|
||||
.error
|
||||
)
|
||||
: undefined,
|
||||
arguments: safeArgs,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
@@ -48,12 +107,12 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
recordActivity({
|
||||
name: serverFnName,
|
||||
player: userId !== 'unknown' ? userId : undefined,
|
||||
name,
|
||||
player: playerId,
|
||||
duration,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
arguments: data,
|
||||
arguments: safeArgs,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
||||
|
||||
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next }) => {
|
||||
}).server(async ({ next, serverFnMeta }) => {
|
||||
const request = getRequest();
|
||||
|
||||
try {
|
||||
@@ -62,7 +62,12 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||
return next({ context });
|
||||
}
|
||||
|
||||
logger.error("Unauthorized user in admin function.", context);
|
||||
logger.error("Unauthorized user in admin function.", {
|
||||
userAuthId: context.userAuthId,
|
||||
roles: context.roles,
|
||||
});
|
||||
const { recordDeniedServerFn } = await import("./activities");
|
||||
recordDeniedServerFn(request, context.metadata, serverFnMeta);
|
||||
throw new Error("Unauthorized");
|
||||
} catch (error: any) {
|
||||
if (error.message === "SESSION_REFRESH_REQUIRED") {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||
|
||||
// Excludes the TanStack Start plugin: its SSR/router codegen isn't needed for unit tests.
|
||||
export default defineConfig({
|
||||
plugins: [tsConfigPaths({ projects: ['./tsconfig.json'] })],
|
||||
test: {
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.{ts,tsx}'],
|
||||
restoreMocks: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user