James Read 3 ay önce
ebeveyn
işleme
00cc130414

+ 110 - 92
frontend/package-lock.json

@@ -11,19 +11,19 @@
 			"dependencies": {
 				"@connectrpc/connect": "^2.1.1",
 				"@connectrpc/connect-web": "^2.1.1",
-				"@hugeicons/core-free-icons": "^3.3.0",
-				"@hugeicons/vue": "^1.0.4",
+				"@hugeicons/core-free-icons": "^4.0.0",
+				"@hugeicons/vue": "^1.0.5",
 				"@vitejs/plugin-vue": "^6.0.4",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.14.0",
+				"picocrank": "^1.14.1",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^31.0.0",
 				"vite": "^7.3.1",
-				"vue": "^3.5.29",
-				"vue-i18n": "^11.2.8",
+				"vue": "^3.5.30",
+				"vue-i18n": "^11.3.0",
 				"vue-router": "^5.0.3"
 			},
 			"devDependencies": {
@@ -906,15 +906,15 @@
 			}
 		},
 		"node_modules/@hugeicons/core-free-icons": {
-			"version": "3.3.0",
-			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-3.3.0.tgz",
-			"integrity": "sha512-qYyr4JQ2eQIHTSTbITvnJvs6ERNK64D9gpwZnf2IyuG0exzqfyABLO/oTB71FB3RZPfu1GbwycdiGSo46apjMQ==",
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.0.0.tgz",
+			"integrity": "sha512-bzfbKumv3ke3ajbe2MyXi9i0I/cdsZ6n/mO9EfIPNSL++pHLqs7nSGRIVUtjF4xrrEyVkfhxssv4Jek8DPA6gA==",
 			"license": "MIT"
 		},
 		"node_modules/@hugeicons/vue": {
-			"version": "1.0.4",
-			"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.4.tgz",
-			"integrity": "sha512-OtFEXbyW5jYUig98C/n/HygktLvfF5Ga6nN6gK8R0E0jCrVw3EfgoZZVXqo+xGxyIjH5R1wdbg6nJrtf6mzLKQ==",
+			"version": "1.0.5",
+			"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.5.tgz",
+			"integrity": "sha512-kaouUZceXtdDfupfiqqfn40tIyRBF/fcEvCfY96hZIXZ3JMsqpwhCDqiqoj+B5bMEUXOuvz3npNyKI5+7iPfYA==",
 			"license": "MIT",
 			"peerDependencies": {
 				"vue": "^2.6.0 || ^3.0.0"
@@ -962,13 +962,30 @@
 			"license": "MIT"
 		},
 		"node_modules/@intlify/core-base": {
-			"version": "11.2.8",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.8.tgz",
-			"integrity": "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA==",
+			"version": "11.3.0",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.3.0.tgz",
+			"integrity": "sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==",
+			"license": "MIT",
+			"dependencies": {
+				"@intlify/devtools-types": "11.3.0",
+				"@intlify/message-compiler": "11.3.0",
+				"@intlify/shared": "11.3.0"
+			},
+			"engines": {
+				"node": ">= 16"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/kazupon"
+			}
+		},
+		"node_modules/@intlify/devtools-types": {
+			"version": "11.3.0",
+			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.3.0.tgz",
+			"integrity": "sha512-G9CNL4WpANWVdUjubOIIS7/D2j/0j+1KJmhBJxHilWNKr9mmt3IjFV3Hq4JoBP23uOoC5ynxz/FHZ42M+YxfGw==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/message-compiler": "11.2.8",
-				"@intlify/shared": "11.2.8"
+				"@intlify/core-base": "11.3.0",
+				"@intlify/shared": "11.3.0"
 			},
 			"engines": {
 				"node": ">= 16"
@@ -978,12 +995,12 @@
 			}
 		},
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.2.8",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.8.tgz",
-			"integrity": "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ==",
+			"version": "11.3.0",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.3.0.tgz",
+			"integrity": "sha512-RAJp3TMsqohg/Wa7bVF3cChRhecSYBLrTCQSj7j0UtWVFLP+6iEJoE2zb7GU5fp+fmG5kCbUdzhmlAUCWXiUJw==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/shared": "11.2.8",
+				"@intlify/shared": "11.3.0",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
@@ -994,9 +1011,9 @@
 			}
 		},
 		"node_modules/@intlify/shared": {
-			"version": "11.2.8",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.8.tgz",
-			"integrity": "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw==",
+			"version": "11.3.0",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.3.0.tgz",
+			"integrity": "sha512-LC6P/uay7rXL5zZ5+5iRJfLs/iUN8apu9tm8YqQVmW3Uq3X4A0dOFUIDuAmB7gAC29wTHOS3EiN/IosNSz0eNQ==",
 			"license": "MIT",
 			"engines": {
 				"node": ">= 16"
@@ -1436,53 +1453,53 @@
 			}
 		},
 		"node_modules/@vue/compiler-core": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
-			"integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
+			"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/parser": "^7.29.0",
-				"@vue/shared": "3.5.29",
+				"@vue/shared": "3.5.30",
 				"entities": "^7.0.1",
 				"estree-walker": "^2.0.2",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"node_modules/@vue/compiler-dom": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
-			"integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
+			"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-core": "3.5.29",
-				"@vue/shared": "3.5.29"
+				"@vue/compiler-core": "3.5.30",
+				"@vue/shared": "3.5.30"
 			}
 		},
 		"node_modules/@vue/compiler-sfc": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
-			"integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
+			"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
 			"license": "MIT",
 			"dependencies": {
 				"@babel/parser": "^7.29.0",
-				"@vue/compiler-core": "3.5.29",
-				"@vue/compiler-dom": "3.5.29",
-				"@vue/compiler-ssr": "3.5.29",
-				"@vue/shared": "3.5.29",
+				"@vue/compiler-core": "3.5.30",
+				"@vue/compiler-dom": "3.5.30",
+				"@vue/compiler-ssr": "3.5.30",
+				"@vue/shared": "3.5.30",
 				"estree-walker": "^2.0.2",
 				"magic-string": "^0.30.21",
-				"postcss": "^8.5.6",
+				"postcss": "^8.5.8",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"node_modules/@vue/compiler-ssr": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
-			"integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
+			"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.29",
-				"@vue/shared": "3.5.29"
+				"@vue/compiler-dom": "3.5.30",
+				"@vue/shared": "3.5.30"
 			}
 		},
 		"node_modules/@vue/devtools-api": {
@@ -1516,53 +1533,53 @@
 			}
 		},
 		"node_modules/@vue/reactivity": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
-			"integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
+			"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/shared": "3.5.29"
+				"@vue/shared": "3.5.30"
 			}
 		},
 		"node_modules/@vue/runtime-core": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
-			"integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
+			"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.29",
-				"@vue/shared": "3.5.29"
+				"@vue/reactivity": "3.5.30",
+				"@vue/shared": "3.5.30"
 			}
 		},
 		"node_modules/@vue/runtime-dom": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
-			"integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
+			"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/reactivity": "3.5.29",
-				"@vue/runtime-core": "3.5.29",
-				"@vue/shared": "3.5.29",
+				"@vue/reactivity": "3.5.30",
+				"@vue/runtime-core": "3.5.30",
+				"@vue/shared": "3.5.30",
 				"csstype": "^3.2.3"
 			}
 		},
 		"node_modules/@vue/server-renderer": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
-			"integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
+			"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-ssr": "3.5.29",
-				"@vue/shared": "3.5.29"
+				"@vue/compiler-ssr": "3.5.30",
+				"@vue/shared": "3.5.30"
 			},
 			"peerDependencies": {
-				"vue": "3.5.29"
+				"vue": "3.5.30"
 			}
 		},
 		"node_modules/@vue/shared": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
-			"integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
+			"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
 			"license": "MIT"
 		},
 		"node_modules/@xterm/addon-fit": {
@@ -4875,19 +4892,19 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.14.0",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.0.tgz",
-			"integrity": "sha512-ksjqPHFMFE6ENaIXjhund50wocFmaLy22jYgWlWikugHBdd/0YlHfOOuoIMn0wKV8bSrJhcM3pQug/qz45Bc4g==",
+			"version": "1.14.1",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.14.1.tgz",
+			"integrity": "sha512-N/aGK/deicXevv+n3zWaKrUe66d4QYQ/7SPinEr4fprljNpXQHSl2EBDlmTNlLoujR3QJHClfRO8Gusj2Hs5MQ==",
 			"license": "ISC",
 			"dependencies": {
-				"@hugeicons/core-free-icons": "^3.1.1",
-				"@hugeicons/vue": "^1.0.4",
+				"@hugeicons/core-free-icons": "^4.0.0",
+				"@hugeicons/vue": "^1.0.5",
 				"@vitejs/plugin-vue": "^6.0.4",
 				"femtocrank": "^2.5.0",
 				"unplugin-vue-components": "^31.0.0",
 				"vite": "^7.3.1",
-				"vue": "^3.5.28",
-				"vue-router": "^5.0.2"
+				"vue": "^3.5.30",
+				"vue-router": "^5.0.3"
 			}
 		},
 		"node_modules/picomatch": {
@@ -5007,9 +5024,9 @@
 			}
 		},
 		"node_modules/postcss": {
-			"version": "8.5.6",
-			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
-			"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+			"version": "8.5.8",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+			"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
 			"funding": [
 				{
 					"type": "opencollective",
@@ -6603,16 +6620,16 @@
 			}
 		},
 		"node_modules/vue": {
-			"version": "3.5.29",
-			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
-			"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
+			"version": "3.5.30",
+			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
+			"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.29",
-				"@vue/compiler-sfc": "3.5.29",
-				"@vue/runtime-dom": "3.5.29",
-				"@vue/server-renderer": "3.5.29",
-				"@vue/shared": "3.5.29"
+				"@vue/compiler-dom": "3.5.30",
+				"@vue/compiler-sfc": "3.5.30",
+				"@vue/runtime-dom": "3.5.30",
+				"@vue/server-renderer": "3.5.30",
+				"@vue/shared": "3.5.30"
 			},
 			"peerDependencies": {
 				"typescript": "*"
@@ -6624,13 +6641,14 @@
 			}
 		},
 		"node_modules/vue-i18n": {
-			"version": "11.2.8",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.8.tgz",
-			"integrity": "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg==",
+			"version": "11.3.0",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.3.0.tgz",
+			"integrity": "sha512-1J+xDfDJTLhDxElkd3+XUhT7FYSZd2b8pa7IRKGxhWH/8yt6PTvi3xmWhGwhYT5EaXdatui11pF2R6tL73/zPA==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.2.8",
-				"@intlify/shared": "11.2.8",
+				"@intlify/core-base": "11.3.0",
+				"@intlify/devtools-types": "11.3.0",
+				"@intlify/shared": "11.3.0",
 				"@vue/devtools-api": "^6.5.0"
 			},
 			"engines": {

+ 5 - 5
frontend/package.json

@@ -24,19 +24,19 @@
 	"dependencies": {
 		"@connectrpc/connect": "^2.1.1",
 		"@connectrpc/connect-web": "^2.1.1",
-		"@hugeicons/core-free-icons": "^3.3.0",
-		"@hugeicons/vue": "^1.0.4",
+		"@hugeicons/core-free-icons": "^4.0.0",
+		"@hugeicons/vue": "^1.0.5",
 		"@vitejs/plugin-vue": "^6.0.4",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.14.0",
+		"picocrank": "^1.14.1",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^31.0.0",
 		"vite": "^7.3.1",
-		"vue": "^3.5.29",
-		"vue-i18n": "^11.2.8",
+		"vue": "^3.5.30",
+		"vue-i18n": "^11.3.0",
 		"vue-router": "^5.0.3"
 	}
 }

+ 14 - 4
frontend/resources/vue/components/ConnectionBanner.vue

@@ -1,7 +1,9 @@
 <template>
     <span id="connection-banner" v-if="!connectionState.connected" class="inline-notification critical user-info-connection">
         <span class="connection-banner-sr-only" role="status">{{ staticAnnouncement }}</span>
-        <span aria-hidden="true">{{ bannerText }}</span>
+        <span aria-hidden="true">
+            <a :href="websocketDocsUrl" target="_blank" rel="noopener noreferrer" class="connection-banner-link">{{ linkText }}</a>{{ bannerSuffix }}
+        </span>
     </span>
 </template>
 
@@ -12,6 +14,10 @@ import { connectionState } from '../stores/connectionState.js'
 
 const { t } = useI18n()
 
+const websocketDocsUrl = 'https://docs.olivetin.app/troubleshooting/err-websocket-connection.html'
+
+const linkText = computed(() => t('disconnected-banner-link-text'))
+
 function formatShortRelative(ms) {
   if (ms < 0) return '0s'
   const secs = Math.floor(ms / 1000)
@@ -49,16 +55,16 @@ onUnmounted(() => {
 
 const staticAnnouncement = computed(() => t('disconnected-banner-announcement'))
 
-const bannerText = computed(() => {
+const bannerSuffix = computed(() => {
   const at = connectionState.disconnectedAt
   const next = connectionState.nextReconnectAt
   const n = now.value
   const disconnectedSince = formatShortTime(at)
   if (next != null && next > n) {
     const reconnectIn = formatShortRelative(next - n)
-    return t('disconnected-banner', { disconnectedSince, reconnectIn })
+    return t('disconnected-banner-suffix', { disconnectedSince, reconnectIn })
   }
-  return t('disconnected-banner-reconnecting', { disconnectedSince })
+  return t('disconnected-banner-suffix-reconnecting', { disconnectedSince })
 })
 </script>
 
@@ -70,6 +76,10 @@ const bannerText = computed(() => {
     border: 0;
     margin: 0;
 }
+.connection-banner-link {
+    color: inherit;
+    text-decoration: underline;
+}
 .connection-banner-sr-only {
     position: absolute;
     width: 1px;

+ 15 - 10
lang/combined_output.json

@@ -21,9 +21,10 @@
             "diagnostics.useragent-data-error": "Fehler beim Abrufen von userAgentData",
             "diagnostics.where-to-find-help": "Wo Sie Hilfe finden",
             "disconnected": "Getrennt",
-            "disconnected-banner": "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.",
             "disconnected-banner-announcement": "Events-Websocket getrennt.",
-            "disconnected-banner-reconnecting": "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…",
+            "disconnected-banner-link-text": "Events-Websocket getrennt",
+            "disconnected-banner-suffix": " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}.",
+            "disconnected-banner-suffix-reconnecting": " seit {disconnectedSince}. Verbindungsversuch…",
             "docs": "Dokumentation",
             "language-dialog.browser-languages": "Browser-Sprachen",
             "language-dialog.close": "Schließen",
@@ -79,9 +80,10 @@
             "diagnostics.useragent-data-error": "Error retrieving userAgentData",
             "diagnostics.where-to-find-help": "Where to find help",
             "disconnected": "Disconnected",
-            "disconnected-banner": "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}.",
             "disconnected-banner-announcement": "Events websocket disconnected.",
-            "disconnected-banner-reconnecting": "Events websocket disconnected since {disconnectedSince}. Trying reconnect…",
+            "disconnected-banner-link-text": "Events websocket disconnected",
+            "disconnected-banner-suffix": " since {disconnectedSince}. Trying reconnect in {reconnectIn}.",
+            "disconnected-banner-suffix-reconnecting": " since {disconnectedSince}. Trying reconnect…",
             "docs": "Documentation",
             "language-dialog.browser-languages": "Browser languages",
             "language-dialog.close": "Close",
@@ -137,9 +139,10 @@
             "diagnostics.useragent-data-error": "Error al recuperar userAgentData",
             "diagnostics.where-to-find-help": "Dónde encontrar ayuda",
             "disconnected": "Desconectado",
-            "disconnected-banner": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.",
             "disconnected-banner-announcement": "Websocket de eventos desconectado.",
-            "disconnected-banner-reconnecting": "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…",
+            "disconnected-banner-link-text": "Websocket de eventos desconectado",
+            "disconnected-banner-suffix": " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}.",
+            "disconnected-banner-suffix-reconnecting": " desde {disconnectedSince}. Reintentando conexión…",
             "docs": "Documentación",
             "language-dialog.browser-languages": "Idiomas del navegador",
             "language-dialog.close": "Cerrar",
@@ -195,9 +198,10 @@
             "diagnostics.useragent-data-error": "Errore nel recupero di userAgentData",
             "diagnostics.where-to-find-help": "Dove trovare aiuto",
             "disconnected": "Disconnesso",
-            "disconnected-banner": "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.",
             "disconnected-banner-announcement": "Websocket eventi disconnesso.",
-            "disconnected-banner-reconnecting": "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…",
+            "disconnected-banner-link-text": "Websocket eventi disconnesso",
+            "disconnected-banner-suffix": " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}.",
+            "disconnected-banner-suffix-reconnecting": " dalle {disconnectedSince}. Tentativo di connessione…",
             "docs": "Documentazione",
             "language-dialog.browser-languages": "Lingue del browser",
             "language-dialog.close": "Chiudi",
@@ -253,9 +257,10 @@
             "diagnostics.useragent-data-error": "检索 userAgentData 时出错",
             "diagnostics.where-to-find-help": "在哪里找到帮助",
             "disconnected": "已断开连接",
-            "disconnected-banner": "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。",
             "disconnected-banner-announcement": "事件 WebSocket 已断开。",
-            "disconnected-banner-reconnecting": "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…",
+            "disconnected-banner-link-text": "事件 WebSocket 已断开",
+            "disconnected-banner-suffix": "自 {disconnectedSince}。{reconnectIn} 后尝试重连。",
+            "disconnected-banner-suffix-reconnecting": "自 {disconnectedSince}。正在尝试重连…",
             "docs": "文档",
             "language-dialog.browser-languages": "浏览器语言",
             "language-dialog.close": "关闭",

+ 3 - 2
lang/de-DE.yaml

@@ -8,9 +8,10 @@ translations:
   connected: Verbunden
   disconnected: Getrennt
   reconnecting: Verbinde erneut…
-  disconnected-banner: "Events-Websocket getrennt seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}."
-  disconnected-banner-reconnecting: "Events-Websocket getrennt seit {disconnectedSince}. Verbindungsversuch…"
   disconnected-banner-announcement: Events-Websocket getrennt.
+  disconnected-banner-link-text: "Events-Websocket getrennt"
+  disconnected-banner-suffix: " seit {disconnectedSince}. Erneuter Verbindungsversuch in {reconnectIn}."
+  disconnected-banner-suffix-reconnecting: " seit {disconnectedSince}. Verbindungsversuch…"
   login-button: Login
   raise-issue: Ein Problem melden auf GitHub
   docs: Dokumentation

+ 3 - 2
lang/en.yaml

@@ -10,9 +10,10 @@ translations:
   connected: Connected
   disconnected: Disconnected
   reconnecting: Reconnecting…
-  disconnected-banner: "Events websocket disconnected since {disconnectedSince}. Trying reconnect in {reconnectIn}."
-  disconnected-banner-reconnecting: "Events websocket disconnected since {disconnectedSince}. Trying reconnect…"
   disconnected-banner-announcement: Events websocket disconnected.
+  disconnected-banner-link-text: "Events websocket disconnected"
+  disconnected-banner-suffix: " since {disconnectedSince}. Trying reconnect in {reconnectIn}."
+  disconnected-banner-suffix-reconnecting: " since {disconnectedSince}. Trying reconnect…"
   login-button: Login
   logs.title: Logs
   logs.page-description: This is a list of logs from actions that have been executed. You can filter the list by action title.

+ 3 - 2
lang/es-ES.yaml

@@ -8,9 +8,10 @@ translations:
   connected: Conectado
   disconnected: Desconectado
   reconnecting: Reconectando…
-  disconnected-banner: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión en {reconnectIn}."
-  disconnected-banner-reconnecting: "Websocket de eventos desconectado desde {disconnectedSince}. Reintentando conexión…"
   disconnected-banner-announcement: Websocket de eventos desconectado.
+  disconnected-banner-link-text: "Websocket de eventos desconectado"
+  disconnected-banner-suffix: " desde {disconnectedSince}. Reintentando conexión en {reconnectIn}."
+  disconnected-banner-suffix-reconnecting: " desde {disconnectedSince}. Reintentando conexión…"
   login-button: Iniciar sesión
   raise-issue: Reportar un problema en GitHub
   docs: Documentación

+ 3 - 2
lang/it-IT.yaml

@@ -9,9 +9,10 @@ translations:
   connected: Connesso
   disconnected: Disconnesso
   reconnecting: Riconnessione…
-  disconnected-banner: "Websocket eventi disconnesso dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}."
-  disconnected-banner-reconnecting: "Websocket eventi disconnesso dalle {disconnectedSince}. Tentativo di connessione…"
   disconnected-banner-announcement: Websocket eventi disconnesso.
+  disconnected-banner-link-text: "Websocket eventi disconnesso"
+  disconnected-banner-suffix: " dalle {disconnectedSince}. Nuovo tentativo tra {reconnectIn}."
+  disconnected-banner-suffix-reconnecting: " dalle {disconnectedSince}. Tentativo di connessione…"
   login-button: Login
   raise-issue: Segnala un problema su GitHub
   logs.title: Registri

+ 3 - 2
lang/zh-Hans-CN.yaml

@@ -8,9 +8,10 @@ translations:
   connected: 已连接
   disconnected: 已断开连接
   reconnecting: 正在重新连接…
-  disconnected-banner: "事件 WebSocket 自 {disconnectedSince} 已断开。{reconnectIn} 后尝试重连。"
-  disconnected-banner-reconnecting: "事件 WebSocket 自 {disconnectedSince} 已断开。正在尝试重连…"
   disconnected-banner-announcement: 事件 WebSocket 已断开。
+  disconnected-banner-link-text: "事件 WebSocket 已断开"
+  disconnected-banner-suffix: "自 {disconnectedSince}。{reconnectIn} 后尝试重连。"
+  disconnected-banner-suffix-reconnecting: "自 {disconnectedSince}。正在尝试重连…"
   login-button: 登录
   raise-issue: 在 GitHub 上报告问题
   docs: 文档

+ 101 - 43
service/internal/api/api.go

@@ -60,6 +60,20 @@ type streamingClient struct {
 	AuthenticatedUser *authpublic.AuthenticatedUser
 }
 
+// trySendEventToClient sends msg to the client's channel. Returns false if the channel is full (client should be removed).
+func (api *oliveTinAPI) trySendEventToClient(client *streamingClient, msg *apiv1.EventStreamResponse) bool {
+	if client == nil || msg == nil {
+		return false
+	}
+	select {
+	case client.channel <- msg:
+		return true
+	default:
+		log.Warnf("EventStream: client channel is full, removing client")
+		return false
+	}
+}
+
 func (api *oliveTinAPI) KillAction(ctx ctx.Context, req *connect.Request[apiv1.KillActionRequest]) (*connect.Response[apiv1.KillActionResponse], error) {
 	ret := &apiv1.KillActionResponse{
 		ExecutionTrackingId: req.Msg.ExecutionTrackingId,
@@ -589,9 +603,20 @@ func isValidLogEntry(e *executor.InternalLogEntry) bool {
 
 // isLogEntryAllowed checks if a log entry is allowed to be viewed by the user.
 func (api *oliveTinAPI) isLogEntryAllowed(e *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
+	if user == nil || !isValidLogEntry(e) {
+		return false
+	}
 	return acl.IsAllowedLogs(api.cfg, user, e.Binding.Action)
 }
 
+// mayViewExecutionEvent returns whether the user is allowed to receive this execution event (for EventStream ACL).
+func (api *oliveTinAPI) mayViewExecutionEvent(entry *executor.InternalLogEntry, user *authpublic.AuthenticatedUser) bool {
+	if user == nil {
+		return false
+	}
+	return isValidLogEntry(entry) && api.isLogEntryAllowed(entry, user)
+}
+
 // buildEmptyPageResponse creates a response for an empty page.
 func buildEmptyPageResponse(page pageInfo) *apiv1.GetActionLogsResponse {
 	return &apiv1.GetActionLogsResponse{
@@ -886,6 +911,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 }
 
 func (api *oliveTinAPI) removeClient(clientToRemove *streamingClient) {
+	if clientToRemove == nil {
+		return
+	}
 	api.streamingClientsMutex.Lock()
 	delete(api.streamingClients, clientToRemove)
 	api.streamingClientsMutex.Unlock()
@@ -915,50 +943,62 @@ func (api *oliveTinAPI) OnActionMapRebuilt() {
 
 func (api *oliveTinAPI) OnExecutionStarted(ex *executor.InternalLogEntry) {
 	toRemove := []*streamingClient{}
-
 	for _, client := range api.copyOfStreamingClients() {
-		select {
-		case client.channel <- &apiv1.EventStreamResponse{
-			Event: &apiv1.EventStreamResponse_ExecutionStarted{
-				ExecutionStarted: &apiv1.EventExecutionStarted{
-					LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
-				},
-			},
-		}:
-		default:
-			log.Warnf("EventStream: client channel is full, removing client")
-			toRemove = append(toRemove, client)
-		}
+		api.maybeSendExecutionStarted(client, ex, &toRemove)
 	}
-
 	for _, client := range toRemove {
 		api.removeClient(client)
 	}
 }
 
+func (api *oliveTinAPI) maybeSendExecutionStarted(client *streamingClient, ex *executor.InternalLogEntry, toRemove *[]*streamingClient) {
+	if client == nil {
+		return
+	}
+	if !api.mayViewExecutionEvent(ex, client.AuthenticatedUser) {
+		return
+	}
+	msg := &apiv1.EventStreamResponse{
+		Event: &apiv1.EventStreamResponse_ExecutionStarted{
+			ExecutionStarted: &apiv1.EventExecutionStarted{
+				LogEntry: api.internalLogEntryToPb(ex, client.AuthenticatedUser),
+			},
+		},
+	}
+	if !api.trySendEventToClient(client, msg) {
+		*toRemove = append(*toRemove, client)
+	}
+}
+
 func (api *oliveTinAPI) OnExecutionFinished(ile *executor.InternalLogEntry) {
 	toRemove := []*streamingClient{}
-
 	for _, client := range api.copyOfStreamingClients() {
-		select {
-		case client.channel <- &apiv1.EventStreamResponse{
-			Event: &apiv1.EventStreamResponse_ExecutionFinished{
-				ExecutionFinished: &apiv1.EventExecutionFinished{
-					LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
-				},
-			},
-		}:
-		default:
-			log.Warnf("EventStream: client channel is full, removing client")
-			toRemove = append(toRemove, client)
-		}
+		api.maybeSendExecutionFinished(client, ile, &toRemove)
 	}
-
 	for _, client := range toRemove {
 		api.removeClient(client)
 	}
 }
 
+func (api *oliveTinAPI) maybeSendExecutionFinished(client *streamingClient, ile *executor.InternalLogEntry, toRemove *[]*streamingClient) {
+	if client == nil {
+		return
+	}
+	if !api.mayViewExecutionEvent(ile, client.AuthenticatedUser) {
+		return
+	}
+	msg := &apiv1.EventStreamResponse{
+		Event: &apiv1.EventStreamResponse_ExecutionFinished{
+			ExecutionFinished: &apiv1.EventExecutionFinished{
+				LogEntry: api.internalLogEntryToPb(ile, client.AuthenticatedUser),
+			},
+		},
+	}
+	if !api.trySendEventToClient(client, msg) {
+		*toRemove = append(*toRemove, client)
+	}
+}
+
 func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[apiv1.GetDiagnosticsRequest]) (*connect.Response[apiv1.GetDiagnosticsResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	if err := api.checkDashboardAccess(user); err != nil {
@@ -1128,29 +1168,47 @@ func buildAdditionalLinks(links []*config.NavigationLink) []*apiv1.AdditionalLin
 }
 
 func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string) {
+	entry := api.getValidLogEntryForStreaming(executionTrackingId)
+	if entry == nil {
+		return
+	}
+	msg := &apiv1.EventStreamResponse{
+		Event: &apiv1.EventStreamResponse_OutputChunk{
+			OutputChunk: &apiv1.EventOutputChunk{
+				Output:              string(content),
+				ExecutionTrackingId: executionTrackingId,
+			},
+		},
+	}
 	toRemove := []*streamingClient{}
-
 	for _, client := range api.copyOfStreamingClients() {
-		select {
-		case client.channel <- &apiv1.EventStreamResponse{
-			Event: &apiv1.EventStreamResponse_OutputChunk{
-				OutputChunk: &apiv1.EventOutputChunk{
-					Output:              string(content),
-					ExecutionTrackingId: executionTrackingId,
-				},
-			},
-		}:
-		default:
-			log.Warnf("EventStream: client channel is full, removing client")
-			toRemove = append(toRemove, client)
-		}
+		api.maybeSendOutputChunk(client, entry, msg, &toRemove)
 	}
-
 	for _, client := range toRemove {
 		api.removeClient(client)
 	}
 }
 
+func (api *oliveTinAPI) getValidLogEntryForStreaming(executionTrackingId string) *executor.InternalLogEntry {
+	entry, ok := api.executor.GetLog(executionTrackingId)
+	if !ok || !isValidLogEntry(entry) {
+		return nil
+	}
+	return entry
+}
+
+func (api *oliveTinAPI) maybeSendOutputChunk(client *streamingClient, entry *executor.InternalLogEntry, msg *apiv1.EventStreamResponse, toRemove *[]*streamingClient) {
+	if client == nil {
+		return
+	}
+	if !api.mayViewExecutionEvent(entry, client.AuthenticatedUser) {
+		return
+	}
+	if !api.trySendEventToClient(client, msg) {
+		*toRemove = append(*toRemove, client)
+	}
+}
+
 func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 

+ 138 - 7
service/internal/api/api_test.go

@@ -2,24 +2,24 @@ package api
 
 import (
 	"context"
+	"net/http"
+	"net/http/httptest"
+	"path"
 	"testing"
+	"time"
 
 	"connectrpc.com/connect"
+	"github.com/google/uuid"
+	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 
-	log "github.com/sirupsen/logrus"
-
 	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
 	apiv1connect "github.com/OliveTin/OliveTin/gen/olivetin/api/v1/apiv1connect"
 	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/executor"
-
-	"net/http"
-	"net/http/httptest"
-	"path"
 )
 
 func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
@@ -338,12 +338,13 @@ func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *Dashboard
 }
 
 // buildViewPermissionTestConfig returns config and users for GHSA view-permission tests:
-// one action "secret_action", ACL "restricted" (view:false) for user "low", ACL "full" (view:true) for user "admin".
+// one action "secret_action", ACL "restricted" (view:false, logs:false) for user "low", ACL "full" (view:true, logs:true) for user "admin".
 func buildViewPermissionTestConfig(t *testing.T) (*config.Config, *authpublic.AuthenticatedUser, *authpublic.AuthenticatedUser) {
 	t.Helper()
 	cfg := config.DefaultConfig()
 	cfg.DefaultPermissions.View = false
 	cfg.DefaultPermissions.Exec = false
+	cfg.DefaultPermissions.Logs = false
 
 	cfg.Actions = append(cfg.Actions, &config.Action{
 		ID:    "secret_action",
@@ -572,3 +573,133 @@ func TestOrderTopLevelDashboardComponents_SortablesSorted(t *testing.T) {
 	assert.Equal(t, "Alpha", out[0].Title, "sortables ordered by title")
 	assert.Equal(t, "Beta", out[1].Title)
 }
+
+// TestEventStreamACLNoLeakToUnauthorizedUser (GHSA-228v-wc5r-j8m7) asserts that EventStream
+// does not send execution events or output chunks to users who are not allowed to view that action's logs.
+func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) {
+	cfg, lowUser, adminUser := buildViewPermissionTestConfig(t)
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	api := newServer(ex)
+
+	binding := ex.FindBindingByID("secret_action")
+	require.NotNil(t, binding, "secret_action binding must exist")
+
+	clientLow, clientAdmin := addEventStreamTestClients(t, api, lowUser, adminUser)
+	defer removeEventStreamTestClients(api, clientLow, clientAdmin)
+
+	runEventStreamTestExecution(t, ex, cfg, binding, adminUser)
+	adminEvents := drainEventStreamUntilFinished(clientAdmin.channel, 2*time.Second)
+	lowEvents := drainEventStreamWithTimeout(clientLow.channel, 50*time.Millisecond)
+
+	assertEventStreamLowUserReceivesNothing(t, lowEvents)
+	assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents)
+}
+
+func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
+	t.Helper()
+	clientLow := &streamingClient{
+		channel:           make(chan *apiv1.EventStreamResponse, 20),
+		AuthenticatedUser: lowUser,
+	}
+	clientAdmin := &streamingClient{
+		channel:           make(chan *apiv1.EventStreamResponse, 20),
+		AuthenticatedUser: adminUser,
+	}
+	api.streamingClientsMutex.Lock()
+	api.streamingClients[clientLow] = struct{}{}
+	api.streamingClients[clientAdmin] = struct{}{}
+	api.streamingClientsMutex.Unlock()
+	return clientLow, clientAdmin
+}
+
+func removeEventStreamTestClients(api *oliveTinAPI, clientLow, clientAdmin *streamingClient) {
+	api.streamingClientsMutex.Lock()
+	delete(api.streamingClients, clientLow)
+	delete(api.streamingClients, clientAdmin)
+	api.streamingClientsMutex.Unlock()
+	close(clientLow.channel)
+	close(clientAdmin.channel)
+}
+
+func runEventStreamTestExecution(t *testing.T, ex *executor.Executor, cfg *config.Config, binding *executor.ActionBinding, adminUser *authpublic.AuthenticatedUser) {
+	t.Helper()
+	execReq := &executor.ExecutionRequest{
+		Binding:           binding,
+		Arguments:         map[string]string{},
+		TrackingID:        uuid.NewString(),
+		Cfg:               cfg,
+		AuthenticatedUser: adminUser,
+	}
+	wg, _ := ex.ExecRequest(execReq)
+	wg.Wait()
+}
+
+func drainEventStreamUntilFinished(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse {
+	var out []*apiv1.EventStreamResponse
+	deadline := time.Now().Add(timeout)
+	for time.Now().Before(deadline) {
+		ev, finished := recvEventStreamOne(ch, 50*time.Millisecond)
+		if ev != nil {
+			out = append(out, ev)
+		}
+		if finished {
+			return out
+		}
+	}
+	return out
+}
+
+func recvEventStreamOne(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) (*apiv1.EventStreamResponse, bool) {
+	select {
+	case ev, ok := <-ch:
+		if !ok {
+			return nil, true
+		}
+		return ev, ev.GetExecutionFinished() != nil
+	case <-time.After(timeout):
+		return nil, true
+	}
+}
+
+func drainEventStreamWithTimeout(ch <-chan *apiv1.EventStreamResponse, timeout time.Duration) []*apiv1.EventStreamResponse {
+	var out []*apiv1.EventStreamResponse
+	for {
+		select {
+		case ev, ok := <-ch:
+			if !ok {
+				return out
+			}
+			out = append(out, ev)
+		case <-time.After(timeout):
+			return out
+		}
+	}
+}
+
+func assertEventStreamLowUserReceivesNothing(t *testing.T, lowEvents []*apiv1.EventStreamResponse) {
+	t.Helper()
+	for _, ev := range lowEvents {
+		assert.Nil(t, ev.GetExecutionStarted(), "low-privilege user must not receive ExecutionStarted")
+		assert.Nil(t, ev.GetExecutionFinished(), "low-privilege user must not receive ExecutionFinished")
+		assert.Nil(t, ev.GetOutputChunk(), "low-privilege user must not receive OutputChunk")
+	}
+	assert.Empty(t, lowEvents, "low-privilege user with Logs:false must not receive any execution events")
+}
+
+func assertEventStreamAdminReceivesSecretActionEvents(t *testing.T, adminEvents []*apiv1.EventStreamResponse) {
+	t.Helper()
+	var gotStarted, gotFinished bool
+	for _, ev := range adminEvents {
+		if ev.GetExecutionStarted() != nil {
+			gotStarted = true
+			assert.Equal(t, "secret_action", ev.GetExecutionStarted().LogEntry.GetBindingId())
+		}
+		if ev.GetExecutionFinished() != nil {
+			gotFinished = true
+			assert.Equal(t, "secret_action", ev.GetExecutionFinished().LogEntry.GetBindingId())
+		}
+	}
+	assert.True(t, gotStarted, "admin must receive ExecutionStarted for secret_action")
+	assert.True(t, gotFinished, "admin must receive ExecutionFinished for secret_action")
+}

+ 1 - 1
service/internal/config/config.go

@@ -281,7 +281,7 @@ func DefaultConfigWithBasePort(basePort int) *Config {
 	config.Prometheus.Enabled = false
 	config.Prometheus.DefaultGoMetrics = false
 	config.Security.HeaderContentSecurityPolicy = true
-	config.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
+	config.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault
 	config.Security.HeaderXContentTypeOptions = true
 	config.Security.HeaderXFrameOptions = true
 	config.Security.XFrameOptions = "DENY"

+ 3 - 0
service/internal/config/constants.go

@@ -0,0 +1,3 @@
+package config
+
+const ContentSecurityPolicyDefault = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"

+ 1 - 1
service/internal/config/sanitize.go

@@ -194,7 +194,7 @@ func (cfg *Config) sanitizeSecurityHeadersCSP() {
 	if !cfg.Security.HeaderContentSecurityPolicy || cfg.Security.ContentSecurityPolicy != "" {
 		return
 	}
-	cfg.Security.ContentSecurityPolicy = "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'none'; base-uri 'self'"
+	cfg.Security.ContentSecurityPolicy = ContentSecurityPolicyDefault
 }
 
 func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {

+ 1 - 4
service/internal/executor/arguments.go

@@ -250,13 +250,10 @@ func typecheckChoiceEntity(value string, arg *config.ActionArgument) error {
 
 func typeSafetyCheckEmail(value string) error {
 	_, err := mail.ParseAddress(value)
-
-	log.Errorf("Email check: %v, %v", err, value)
-
 	if err != nil {
+		log.WithField("type", "email").Debugf("Email argument type check failed")
 		return err
 	}
-
 	return nil
 }