kaneo (minimalist kanban) fork to experiment adding a tangled integration github.com/usekaneo/kaneo
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: notification preferences secrets and locale strings

Tin 4f349ec8 88d57ff3

+353 -153
+14
apps/api/drizzle/0023_encrypt_notification_preference_secrets.sql
··· 1 + UPDATE "user_notification_preference" 2 + SET "ntfy_token" = NULL 3 + WHERE "ntfy_token" IS NOT NULL 4 + AND "ntfy_token" NOT LIKE 'enc:v1:%'; 5 + --> statement-breakpoint 6 + UPDATE "user_notification_preference" 7 + SET "gotify_token" = NULL 8 + WHERE "gotify_token" IS NOT NULL 9 + AND "gotify_token" NOT LIKE 'enc:v1:%'; 10 + --> statement-breakpoint 11 + UPDATE "user_notification_preference" 12 + SET "webhook_secret" = NULL 13 + WHERE "webhook_secret" IS NOT NULL 14 + AND "webhook_secret" NOT LIKE 'enc:v1:%';
+7
apps/api/drizzle/meta/_journal.json
··· 162 162 "when": 1775039833883, 163 163 "tag": "0022_real_deathstrike", 164 164 "breakpoints": true 165 + }, 166 + { 167 + "idx": 23, 168 + "version": "7", 169 + "when": 1775102400000, 170 + "tag": "0023_encrypt_notification_preference_secrets", 171 + "breakpoints": true 165 172 } 166 173 ] 167 174 }
+2 -2
apps/api/src/database/relations.ts
··· 29 29 workspaceUserTable, 30 30 } from "./schema"; 31 31 32 - export const userTableRelations = relations(userTable, ({ many }) => ({ 32 + export const userTableRelations = relations(userTable, ({ many, one }) => ({ 33 33 sessions: many(sessionTable), 34 34 accounts: many(accountTable), 35 35 teamMembers: many(teamMemberTable), ··· 41 41 comments: many(commentTable), 42 42 assets: many(assetTable), 43 43 notifications: many(notificationTable), 44 - notificationPreference: many(userNotificationPreferenceTable), 44 + notificationPreference: one(userNotificationPreferenceTable), 45 45 notificationWorkspaceRules: many(userNotificationWorkspaceRuleTable), 46 46 sentInvitations: many(invitationTable), 47 47 apikeys: many(apikeyTable),
+28 -20
apps/api/src/notification-preferences/delivery.ts
··· 12 12 workspaceTable, 13 13 } from "../database/schema"; 14 14 import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config"; 15 + import { decryptSecret } from "./secrets"; 15 16 16 17 const DEFAULT_OUTBOUND_FETCH_TIMEOUT_MS = 15_000; 17 18 ··· 354 355 return; 355 356 } 356 357 358 + const decryptedPreference = { 359 + ...preference, 360 + ntfyToken: decryptSecret(preference.ntfyToken), 361 + gotifyToken: decryptSecret(preference.gotifyToken), 362 + webhookSecret: decryptSecret(preference.webhookSecret), 363 + }; 364 + 357 365 const rule = await db.query.userNotificationWorkspaceRuleTable.findFirst({ 358 366 where: and( 359 367 eq(userNotificationWorkspaceRuleTable.userId, notification.userId), ··· 369 377 } 370 378 371 379 if ( 372 - context.projectId && 373 380 rule.projectMode === "selected" && 374 - !rule.selectedProjects.some( 375 - (project) => project.projectId === context.projectId, 376 - ) 381 + (!context.projectId || 382 + !rule.selectedProjects.some( 383 + (project) => project.projectId === context.projectId, 384 + )) 377 385 ) { 378 386 return; 379 387 } ··· 425 433 426 434 const deliveries: Array<Promise<void>> = []; 427 435 428 - if (preference.emailEnabled && rule.emailEnabled && user.email) { 436 + if (decryptedPreference.emailEnabled && rule.emailEnabled && user.email) { 429 437 deliveries.push( 430 438 sendNotificationEmail(user.email, content.title, { 431 439 title: content.title, ··· 438 446 } 439 447 440 448 if ( 441 - preference.ntfyEnabled && 442 - preference.ntfyServerUrl && 443 - preference.ntfyTopic && 449 + decryptedPreference.ntfyEnabled && 450 + decryptedPreference.ntfyServerUrl && 451 + decryptedPreference.ntfyTopic && 444 452 rule.ntfyEnabled 445 453 ) { 446 454 deliveries.push( 447 455 sendNtfyNotification({ 448 - serverUrl: preference.ntfyServerUrl, 449 - topic: preference.ntfyTopic, 450 - token: preference.ntfyToken, 456 + serverUrl: decryptedPreference.ntfyServerUrl, 457 + topic: decryptedPreference.ntfyTopic, 458 + token: decryptedPreference.ntfyToken, 451 459 title: content.title, 452 460 body: content.body, 453 461 clickUrl: context.taskUrl, ··· 456 464 } 457 465 458 466 if ( 459 - preference.gotifyEnabled && 460 - preference.gotifyServerUrl && 461 - preference.gotifyToken && 467 + decryptedPreference.gotifyEnabled && 468 + decryptedPreference.gotifyServerUrl && 469 + decryptedPreference.gotifyToken && 462 470 rule.gotifyEnabled 463 471 ) { 464 472 deliveries.push( 465 473 sendGotifyNotification({ 466 - serverUrl: preference.gotifyServerUrl, 467 - token: preference.gotifyToken, 474 + serverUrl: decryptedPreference.gotifyServerUrl, 475 + token: decryptedPreference.gotifyToken, 468 476 title: content.title, 469 477 body: content.body, 470 478 clickUrl: context.taskUrl, ··· 473 481 } 474 482 475 483 if ( 476 - preference.webhookEnabled && 477 - preference.webhookUrl && 484 + decryptedPreference.webhookEnabled && 485 + decryptedPreference.webhookUrl && 478 486 rule.webhookEnabled 479 487 ) { 480 488 deliveries.push( 481 489 sendWebhookNotification({ 482 - webhookUrl: preference.webhookUrl, 483 - secret: preference.webhookSecret, 490 + webhookUrl: decryptedPreference.webhookUrl, 491 + secret: decryptedPreference.webhookSecret, 484 492 payload: webhookPayload, 485 493 }), 486 494 );
+95
apps/api/src/notification-preferences/secrets.ts
··· 1 + import { 2 + createCipheriv, 3 + createDecipheriv, 4 + createHash, 5 + randomBytes, 6 + } from "node:crypto"; 7 + 8 + const SECRET_PREFIX = "enc:v1:"; 9 + const SECRET_ALGORITHM = "aes-256-gcm"; 10 + const SECRET_IV_BYTES = 12; 11 + 12 + function getSecretEncryptionKey() { 13 + const rawKey = process.env.NOTIFICATION_SECRET_ENCRYPTION_KEY?.trim(); 14 + if (!rawKey) { 15 + return null; 16 + } 17 + 18 + return createHash("sha256").update(rawKey).digest(); 19 + } 20 + 21 + function requireSecretEncryptionKey() { 22 + const key = getSecretEncryptionKey(); 23 + if (!key) { 24 + throw new Error( 25 + "NOTIFICATION_SECRET_ENCRYPTION_KEY is required to store encrypted notification secrets", 26 + ); 27 + } 28 + 29 + return key; 30 + } 31 + 32 + function encodePart(value: Buffer) { 33 + return value.toString("base64url"); 34 + } 35 + 36 + function decodePart(value: string) { 37 + return Buffer.from(value, "base64url"); 38 + } 39 + 40 + export function isEncryptedSecret(value: string | null | undefined): boolean { 41 + return typeof value === "string" && value.startsWith(SECRET_PREFIX); 42 + } 43 + 44 + export function encryptSecret( 45 + value: string | null | undefined, 46 + ): string | null | undefined { 47 + if (value === undefined || value === null) { 48 + return value; 49 + } 50 + 51 + if (isEncryptedSecret(value)) { 52 + return value; 53 + } 54 + 55 + const iv = randomBytes(SECRET_IV_BYTES); 56 + const cipher = createCipheriv( 57 + SECRET_ALGORITHM, 58 + requireSecretEncryptionKey(), 59 + iv, 60 + ); 61 + const encrypted = Buffer.concat([ 62 + cipher.update(value, "utf8"), 63 + cipher.final(), 64 + ]); 65 + const authTag = cipher.getAuthTag(); 66 + 67 + return `${SECRET_PREFIX}${encodePart(iv)}.${encodePart(authTag)}.${encodePart(encrypted)}`; 68 + } 69 + 70 + export function decryptSecret( 71 + value: string | null | undefined, 72 + ): string | null | undefined { 73 + if (value === undefined || value === null || !isEncryptedSecret(value)) { 74 + return value; 75 + } 76 + 77 + const payload = value.slice(SECRET_PREFIX.length); 78 + const [iv, authTag, encrypted] = payload.split("."); 79 + 80 + if (!iv || !authTag || !encrypted) { 81 + throw new Error("Invalid encrypted notification secret payload"); 82 + } 83 + 84 + const decipher = createDecipheriv( 85 + SECRET_ALGORITHM, 86 + requireSecretEncryptionKey(), 87 + decodePart(iv), 88 + ); 89 + decipher.setAuthTag(decodePart(authTag)); 90 + 91 + return Buffer.concat([ 92 + decipher.update(decodePart(encrypted)), 93 + decipher.final(), 94 + ]).toString("utf8"); 95 + }
+98 -36
apps/api/src/notification-preferences/service.ts
··· 9 9 workspaceUserTable, 10 10 } from "../database/schema"; 11 11 import { assertPublicWebhookDestination } from "../plugins/generic-webhook/config"; 12 + import { decryptSecret, encryptSecret } from "./secrets"; 12 13 13 14 export type NotificationPreferenceProjectMode = "all" | "selected"; 14 15 ··· 87 88 return value.length > 8 ? `${value.slice(0, 4)}…${value.slice(-4)}` : "••••"; 88 89 } 89 90 91 + function normalizeSecretInput( 92 + inputValue: string | null | undefined, 93 + existingValue: string | null | undefined, 94 + ) { 95 + if (inputValue === undefined) { 96 + return normalizeOptionalString(existingValue ?? undefined); 97 + } 98 + 99 + return normalizeOptionalString(inputValue); 100 + } 101 + 90 102 async function assertWorkspaceMembership(userId: string, workspaceId: string) { 91 103 const [membership] = await db 92 104 .select({ workspaceId: workspaceUserTable.workspaceId }) ··· 141 153 where: eq(userNotificationPreferenceTable.userId, userId), 142 154 }); 143 155 156 + const decryptedPreference = preference 157 + ? { 158 + ...preference, 159 + ntfyToken: decryptSecret(preference.ntfyToken), 160 + gotifyToken: decryptSecret(preference.gotifyToken), 161 + webhookSecret: decryptSecret(preference.webhookSecret), 162 + } 163 + : null; 164 + 144 165 const rules = await db.query.userNotificationWorkspaceRuleTable.findMany({ 145 166 where: eq(userNotificationWorkspaceRuleTable.userId, userId), 146 167 with: { ··· 152 173 153 174 return { 154 175 emailAddress, 155 - emailEnabled: preference?.emailEnabled ?? false, 156 - ntfyEnabled: preference?.ntfyEnabled ?? false, 157 - ntfyConfigured: Boolean(preference?.ntfyServerUrl && preference?.ntfyTopic), 158 - ntfyServerUrl: preference?.ntfyServerUrl ?? null, 159 - ntfyTopic: preference?.ntfyTopic ?? null, 160 - ntfyTokenConfigured: Boolean(preference?.ntfyToken), 161 - maskedNtfyToken: maskValue(preference?.ntfyToken), 162 - gotifyEnabled: preference?.gotifyEnabled ?? false, 176 + emailEnabled: decryptedPreference?.emailEnabled ?? false, 177 + ntfyEnabled: decryptedPreference?.ntfyEnabled ?? false, 178 + ntfyConfigured: Boolean( 179 + decryptedPreference?.ntfyServerUrl && decryptedPreference?.ntfyTopic, 180 + ), 181 + ntfyServerUrl: decryptedPreference?.ntfyServerUrl ?? null, 182 + ntfyTopic: decryptedPreference?.ntfyTopic ?? null, 183 + ntfyTokenConfigured: Boolean(decryptedPreference?.ntfyToken), 184 + maskedNtfyToken: maskValue(decryptedPreference?.ntfyToken), 185 + gotifyEnabled: decryptedPreference?.gotifyEnabled ?? false, 163 186 gotifyConfigured: Boolean( 164 - preference?.gotifyServerUrl && preference?.gotifyToken, 187 + decryptedPreference?.gotifyServerUrl && decryptedPreference?.gotifyToken, 165 188 ), 166 - gotifyServerUrl: preference?.gotifyServerUrl ?? null, 167 - gotifyTokenConfigured: Boolean(preference?.gotifyToken), 168 - maskedGotifyToken: maskValue(preference?.gotifyToken), 169 - webhookEnabled: preference?.webhookEnabled ?? false, 170 - webhookConfigured: Boolean(preference?.webhookUrl), 171 - webhookUrl: preference?.webhookUrl ?? null, 172 - webhookSecretConfigured: Boolean(preference?.webhookSecret), 173 - maskedWebhookSecret: maskValue(preference?.webhookSecret), 189 + gotifyServerUrl: decryptedPreference?.gotifyServerUrl ?? null, 190 + gotifyTokenConfigured: Boolean(decryptedPreference?.gotifyToken), 191 + maskedGotifyToken: maskValue(decryptedPreference?.gotifyToken), 192 + webhookEnabled: decryptedPreference?.webhookEnabled ?? false, 193 + webhookConfigured: Boolean(decryptedPreference?.webhookUrl), 194 + webhookUrl: decryptedPreference?.webhookUrl ?? null, 195 + webhookSecretConfigured: Boolean(decryptedPreference?.webhookSecret), 196 + maskedWebhookSecret: maskValue(decryptedPreference?.webhookSecret), 174 197 workspaces: rules.map((rule) => ({ 175 198 id: rule.id, 176 199 workspaceId: rule.workspaceId, ··· 202 225 where: eq(userNotificationPreferenceTable.userId, userId), 203 226 }); 204 227 228 + const decryptedExisting = existing 229 + ? { 230 + ...existing, 231 + ntfyToken: decryptSecret(existing.ntfyToken), 232 + gotifyToken: decryptSecret(existing.gotifyToken), 233 + webhookSecret: decryptSecret(existing.webhookSecret), 234 + } 235 + : null; 236 + 205 237 const ntfyServerUrl = normalizeOptionalString( 206 - input.ntfyServerUrl ?? existing?.ntfyServerUrl, 238 + input.ntfyServerUrl ?? decryptedExisting?.ntfyServerUrl, 207 239 ); 208 240 const ntfyTopic = normalizeOptionalString( 209 - input.ntfyTopic ?? existing?.ntfyTopic, 241 + input.ntfyTopic ?? decryptedExisting?.ntfyTopic, 242 + ); 243 + const ntfyToken = normalizeSecretInput( 244 + input.ntfyToken, 245 + decryptedExisting?.ntfyToken, 210 246 ); 211 - const ntfyToken = normalizeOptionalString(input.ntfyToken ?? undefined); 212 247 const gotifyServerUrl = normalizeOptionalString( 213 - input.gotifyServerUrl ?? existing?.gotifyServerUrl, 248 + input.gotifyServerUrl ?? decryptedExisting?.gotifyServerUrl, 214 249 ); 215 - const gotifyToken = normalizeOptionalString(input.gotifyToken ?? undefined); 250 + const gotifyToken = normalizeSecretInput( 251 + input.gotifyToken, 252 + decryptedExisting?.gotifyToken, 253 + ); 216 254 const webhookUrl = normalizeOptionalString( 217 - input.webhookUrl ?? existing?.webhookUrl, 255 + input.webhookUrl ?? decryptedExisting?.webhookUrl, 218 256 ); 219 - const webhookSecret = normalizeOptionalString( 220 - input.webhookSecret ?? undefined, 257 + const webhookSecret = normalizeSecretInput( 258 + input.webhookSecret, 259 + decryptedExisting?.webhookSecret, 221 260 ); 222 261 223 - const emailEnabled = input.emailEnabled ?? existing?.emailEnabled ?? false; 224 - const ntfyEnabled = input.ntfyEnabled ?? existing?.ntfyEnabled ?? false; 225 - const gotifyEnabled = input.gotifyEnabled ?? existing?.gotifyEnabled ?? false; 262 + const emailEnabled = 263 + input.emailEnabled ?? decryptedExisting?.emailEnabled ?? false; 264 + const ntfyEnabled = 265 + input.ntfyEnabled ?? decryptedExisting?.ntfyEnabled ?? false; 266 + const gotifyEnabled = 267 + input.gotifyEnabled ?? decryptedExisting?.gotifyEnabled ?? false; 226 268 const webhookEnabled = 227 - input.webhookEnabled ?? existing?.webhookEnabled ?? false; 269 + input.webhookEnabled ?? decryptedExisting?.webhookEnabled ?? false; 270 + 271 + const shouldValidateNtfy = 272 + ntfyEnabled || 273 + input.ntfyServerUrl !== undefined || 274 + input.ntfyTopic !== undefined || 275 + input.ntfyToken !== undefined; 276 + 277 + const shouldValidateGotify = 278 + gotifyEnabled || 279 + input.gotifyServerUrl !== undefined || 280 + input.gotifyToken !== undefined; 281 + 282 + const shouldValidateWebhook = 283 + webhookEnabled || 284 + input.webhookUrl !== undefined || 285 + input.webhookSecret !== undefined; 228 286 229 287 if (emailEnabled && !emailAddress) { 230 288 throw new HTTPException(400, { ··· 232 290 }); 233 291 } 234 292 235 - if (ntfyEnabled || ntfyServerUrl || ntfyTopic || ntfyToken !== undefined) { 293 + if (shouldValidateNtfy) { 236 294 if (!ntfyServerUrl || !ntfyTopic) { 237 295 throw new HTTPException(400, { 238 296 message: "ntfy requires a server URL and topic", ··· 250 308 } 251 309 } 252 310 253 - if (gotifyEnabled || gotifyServerUrl || gotifyToken !== undefined) { 311 + if (shouldValidateGotify) { 254 312 if (!gotifyServerUrl || !gotifyToken) { 255 313 throw new HTTPException(400, { 256 314 message: "Gotify requires a server URL and app token", ··· 268 326 } 269 327 } 270 328 271 - if (webhookEnabled || webhookUrl || webhookSecret !== undefined) { 329 + if (shouldValidateWebhook) { 272 330 if (!webhookUrl) { 273 331 throw new HTTPException(400, { 274 332 message: "Webhook notifications require an endpoint URL", ··· 292 350 ntfyServerUrl, 293 351 ntfyTopic, 294 352 ntfyToken: 295 - ntfyToken === undefined ? (existing?.ntfyToken ?? null) : ntfyToken, 353 + input.ntfyToken === undefined 354 + ? (existing?.ntfyToken ?? null) 355 + : (encryptSecret(ntfyToken) ?? null), 296 356 gotifyEnabled, 297 357 gotifyServerUrl, 298 358 gotifyToken: 299 - gotifyToken === undefined ? (existing?.gotifyToken ?? null) : gotifyToken, 359 + input.gotifyToken === undefined 360 + ? (existing?.gotifyToken ?? null) 361 + : (encryptSecret(gotifyToken) ?? null), 300 362 webhookEnabled, 301 363 webhookUrl, 302 364 webhookSecret: 303 - webhookSecret === undefined 365 + input.webhookSecret === undefined 304 366 ? (existing?.webhookSecret ?? null) 305 - : webhookSecret, 367 + : (encryptSecret(webhookSecret) ?? null), 306 368 }; 307 369 308 370 if (existing) {
+14
apps/docs/core/functional/account-notifications.mdx
··· 62 62 - a topic 63 63 - optionally, a bearer token 64 64 65 + ### Gotify 66 + 67 + Gotify requires: 68 + 69 + - a server URL 70 + - an application token (bearer token) 71 + 72 + Setup notes: 73 + 74 + - create an application in your Gotify server before connecting Kaneo 75 + - paste the base server URL, for example `https://gotify.example.com` 76 + - no extra topic or path is required because Kaneo sends directly to the Gotify message API using the application token 77 + - if your Gotify server uses a custom port or TLS, include that in the server URL 78 + 65 79 ### Custom webhook 66 80 67 81 Custom webhook requires:
+95 -95
i18n/el-GR.json
··· 403 403 "toastRuleSaveFailed": "Αποτυχία αποθήκευσης κανόνα ειδοποίησης χώρου εργασίας", 404 404 "toastRuleRemoved": "Αφαιρέθηκε ο κανόνας ειδοποίησης για {{workspaceName}}", 405 405 "toastRuleRemoveFailed": "Αποτυχία αφαίρεσης κανόνα ειδοποίησης χώρου εργασίας", 406 - "toastPreferencesSaved": "Notification preferences saved", 407 - "toastPreferencesSaveFailed": "Failed to save notification preferences", 408 - "toastRuleRemovedGeneric": "Workspace notification rule removed", 409 - "toastRuleSavedGeneric": "Workspace notification rule saved" 406 + "toastPreferencesSaved": "Οι προτιμήσεις ειδοποιήσεων αποθηκεύτηκαν", 407 + "toastPreferencesSaveFailed": "Αποτυχία αποθήκευσης των προτιμήσεων ειδοποιήσεων", 408 + "toastRuleRemovedGeneric": "Ο κανόνας ειδοποίησης του χώρου εργασίας αφαιρέθηκε", 409 + "toastRuleSavedGeneric": "Ο κανόνας ειδοποίησης του χώρου εργασίας αποθηκεύτηκε" 410 410 }, 411 411 "preferencesPage": { 412 412 "title": "Προτιμήσεις", ··· 582 582 "subtitle": "Συνδέστε το έργο σας με εξωτερικά εργαλεία και υπηρεσίες για πιο ομαλή ροή εργασίας.", 583 583 "githubSectionTitle": "Ενσωμάτωση GitHub", 584 584 "githubSectionSubtitle": "Συγχρονίστε εργασίες με GitHub issues και ενεργοποιήστε αμφίδρομο συγχρονισμό.", 585 - "discordSectionSubtitle": "Send project task updates into a Discord channel with a webhook.", 586 - "discordSectionTitle": "Discord Integration", 587 - "genericWebhookSectionSubtitle": "Send project task events to any HTTP endpoint as JSON.", 588 - "genericWebhookSectionTitle": "Generic Webhooks", 589 - "slackSectionSubtitle": "Send project task updates into a Slack channel with an incoming webhook.", 590 - "slackSectionTitle": "Slack Integration" 585 + "discordSectionSubtitle": "Στείλτε ενημερώσεις εργασιών έργου σε κανάλι Discord μέσω webhook.", 586 + "discordSectionTitle": "Ενσωμάτωση Discord", 587 + "genericWebhookSectionSubtitle": "Στείλτε γεγονότα εργασιών έργου σε οποιοδήποτε HTTP endpoint ως JSON.", 588 + "genericWebhookSectionTitle": "Γενικά Webhooks", 589 + "slackSectionSubtitle": "Στείλτε ενημερώσεις εργασιών έργου σε κανάλι Slack μέσω εισερχόμενου webhook.", 590 + "slackSectionTitle": "Ενσωμάτωση Slack" 591 591 }, 592 592 "projectVisibility": { 593 593 "pageTitle": "Ορατότητα έργου", ··· 760 760 "open": "Ανοιχτό" 761 761 }, 762 762 "discordIntegration": { 763 - "channelHint": "Optional label for your reference. Discord controls the actual destination channel from the webhook setup.", 764 - "channelLabel": "Channel name", 763 + "channelHint": "Προαιρετική ετικέτα για τη δική σας αναφορά. Το Discord ελέγχει το πραγματικό κανάλι προορισμού από τη ρύθμιση του webhook.", 764 + "channelLabel": "Όνομα καναλιού", 765 765 "channelPlaceholder": "#team-updates", 766 - "connect": "Connect Discord", 767 - "connected": "Connected", 768 - "connectionHint": "Paste a Discord webhook URL and choose which task events should be posted.", 769 - "connectionTitle": "Discord webhook connection", 770 - "disconnect": "Disconnect", 766 + "connect": "Σύνδεση Discord", 767 + "connected": "Συνδεδεμένο", 768 + "connectionHint": "Επικολλήστε ένα URL webhook του Discord και επιλέξτε ποια γεγονότα εργασιών θα δημοσιεύονται.", 769 + "connectionTitle": "Σύνδεση webhook Discord", 770 + "disconnect": "Αποσύνδεση", 771 771 "events": { 772 - "taskCommentCreated": "New comments", 773 - "taskCreated": "New tasks", 774 - "taskDescriptionChanged": "Description changes", 775 - "taskPriorityChanged": "Priority changes", 776 - "taskStatusChanged": "Status changes", 777 - "taskTitleChanged": "Title changes" 772 + "taskCommentCreated": "Νέα σχόλια", 773 + "taskCreated": "Νέες εργασίες", 774 + "taskDescriptionChanged": "Αλλαγές περιγραφής", 775 + "taskPriorityChanged": "Αλλαγές προτεραιότητας", 776 + "taskStatusChanged": "Αλλαγές κατάστασης", 777 + "taskTitleChanged": "Αλλαγές τίτλου" 778 778 }, 779 - "eventsHint": "Choose which project changes should post to Discord.", 780 - "eventsTitle": "Event notifications", 781 - "paused": "Paused", 782 - "saveChanges": "Save changes", 779 + "eventsHint": "Επιλέξτε ποιες αλλαγές έργου θα δημοσιεύονται στο Discord.", 780 + "eventsTitle": "Ειδοποιήσεις γεγονότων", 781 + "paused": "Σε παύση", 782 + "saveChanges": "Αποθήκευση αλλαγών", 783 783 "toast": { 784 - "disabled": "Discord notifications paused", 785 - "enabled": "Discord notifications enabled", 786 - "removed": "Discord integration removed successfully", 787 - "removeError": "Failed to remove Discord integration", 788 - "saved": "Discord integration saved successfully", 789 - "saveError": "Failed to save Discord integration", 790 - "updateError": "Failed to update Discord integration" 784 + "disabled": "Οι ειδοποιήσεις Discord τέθηκαν σε παύση", 785 + "enabled": "Οι ειδοποιήσεις Discord ενεργοποιήθηκαν", 786 + "removed": "Η ενσωμάτωση Discord αφαιρέθηκε με επιτυχία", 787 + "removeError": "Αποτυχία αφαίρεσης της ενσωμάτωσης Discord", 788 + "saved": "Η ενσωμάτωση Discord αποθηκεύτηκε με επιτυχία", 789 + "saveError": "Αποτυχία αποθήκευσης της ενσωμάτωσης Discord", 790 + "updateError": "Αποτυχία ενημέρωσης της ενσωμάτωσης Discord" 791 791 }, 792 - "update": "Update Discord", 792 + "update": "Ενημέρωση Discord", 793 793 "validation": { 794 - "webhookInvalid": "Enter a valid Discord webhook URL" 794 + "webhookInvalid": "Εισαγάγετε ένα έγκυρο URL webhook Discord" 795 795 }, 796 - "webhookHint": "Create a Discord channel webhook and paste the generated URL here.", 797 - "webhookLabel": "Webhook URL", 796 + "webhookHint": "Δημιουργήστε ένα webhook καναλιού Discord και επικολλήστε εδώ το παραγόμενο URL.", 797 + "webhookLabel": "URL webhook", 798 798 "webhookPlaceholder": "https://discord.com/api/webhooks/..." 799 799 }, 800 800 "genericWebhookIntegration": { 801 - "connect": "Connect webhook", 802 - "connected": "Connected", 803 - "connectionHint": "Send task events to your own endpoint as JSON. A signed X-Kaneo-Signature header is included when a secret is configured.", 804 - "connectionTitle": "Outgoing webhook connection", 805 - "disconnect": "Disconnect", 801 + "connect": "Σύνδεση webhook", 802 + "connected": "Συνδεδεμένο", 803 + "connectionHint": "Στείλτε γεγονότα εργασιών στο δικό σας endpoint ως JSON. Περιλαμβάνεται υπογεγραμμένη κεφαλίδα X-Kaneo-Signature όταν έχει ρυθμιστεί μυστικό.", 804 + "connectionTitle": "Σύνδεση εξερχόμενου webhook", 805 + "disconnect": "Αποσύνδεση", 806 806 "events": { 807 - "taskCommentCreated": "New comments", 808 - "taskCreated": "New tasks", 809 - "taskDescriptionChanged": "Description changes", 810 - "taskPriorityChanged": "Priority changes", 811 - "taskStatusChanged": "Status changes", 812 - "taskTitleChanged": "Title changes" 807 + "taskCommentCreated": "Νέα σχόλια", 808 + "taskCreated": "Νέες εργασίες", 809 + "taskDescriptionChanged": "Αλλαγές περιγραφής", 810 + "taskPriorityChanged": "Αλλαγές προτεραιότητας", 811 + "taskStatusChanged": "Αλλαγές κατάστασης", 812 + "taskTitleChanged": "Αλλαγές τίτλου" 813 813 }, 814 - "eventsHint": "Choose which project changes should trigger outgoing webhooks.", 815 - "eventsTitle": "Event notifications", 816 - "paused": "Paused", 817 - "saveChanges": "Save changes", 818 - "secretHint": "Optional. If provided, Kaneo signs the request body and sends the hex digest in the X-Kaneo-Signature header.", 819 - "secretHintConfigured": "A signing secret is already configured ({{secret}}). Enter a new one to replace it.", 820 - "secretLabel": "Signing secret", 821 - "secretPlaceholder": "Optional shared secret", 814 + "eventsHint": "Επιλέξτε ποιες αλλαγές έργου θα ενεργοποιούν εξερχόμενα webhooks.", 815 + "eventsTitle": "Ειδοποιήσεις γεγονότων", 816 + "paused": "Σε παύση", 817 + "saveChanges": "Αποθήκευση αλλαγών", 818 + "secretHint": "Προαιρετικό. Αν δοθεί, το Kaneo υπογράφει το σώμα του αιτήματος και στέλνει το hex digest στην κεφαλίδα X-Kaneo-Signature.", 819 + "secretHintConfigured": "Έχει ήδη ρυθμιστεί ένα μυστικό υπογραφής ({{secret}}). Εισαγάγετε ένα νέο για να το αντικαταστήσετε.", 820 + "secretLabel": "Μυστικό υπογραφής", 821 + "secretPlaceholder": "Προαιρετικό κοινόχρηστο μυστικό", 822 822 "toast": { 823 - "disabled": "Generic webhook notifications paused", 824 - "enabled": "Generic webhook notifications enabled", 825 - "removed": "Generic webhook integration removed successfully", 826 - "removeError": "Failed to remove generic webhook integration", 827 - "saved": "Generic webhook integration saved successfully", 828 - "saveError": "Failed to save generic webhook integration", 829 - "updateError": "Failed to update generic webhook integration" 823 + "disabled": "Οι ειδοποιήσεις γενικού webhook τέθηκαν σε παύση", 824 + "enabled": "Οι ειδοποιήσεις γενικού webhook ενεργοποιήθηκαν", 825 + "removed": "Η ενσωμάτωση γενικού webhook αφαιρέθηκε με επιτυχία", 826 + "removeError": "Αποτυχία αφαίρεσης της ενσωμάτωσης γενικού webhook", 827 + "saved": "Η ενσωμάτωση γενικού webhook αποθηκεύτηκε με επιτυχία", 828 + "saveError": "Αποτυχία αποθήκευσης της ενσωμάτωσης γενικού webhook", 829 + "updateError": "Αποτυχία ενημέρωσης της ενσωμάτωσης γενικού webhook" 830 830 }, 831 831 "validation": { 832 - "webhookInvalid": "Enter a valid webhook URL" 832 + "webhookInvalid": "Εισαγάγετε ένα έγκυρο URL webhook" 833 833 }, 834 - "webhookHint": "Kaneo sends POST requests with a JSON payload for each enabled event.", 835 - "webhookLabel": "Endpoint URL", 834 + "webhookHint": "Το Kaneo στέλνει αιτήματα POST με JSON payload για κάθε ενεργοποιημένο γεγονός.", 835 + "webhookLabel": "URL endpoint", 836 836 "webhookPlaceholder": "https://example.com/webhooks/kaneo" 837 837 }, 838 838 "slackIntegration": { 839 - "channelHint": "Optional label for your reference. Slack controls the actual destination channel from the webhook setup.", 840 - "channelLabel": "Channel name", 839 + "channelHint": "Προαιρετική ετικέτα για τη δική σας αναφορά. Το Slack ελέγχει το πραγματικό κανάλι προορισμού από τη ρύθμιση του webhook.", 840 + "channelLabel": "Όνομα καναλιού", 841 841 "channelPlaceholder": "#team-updates", 842 - "connect": "Connect Slack", 843 - "connected": "Connected", 844 - "connectionHint": "Paste a Slack incoming webhook URL and choose which task events should be posted.", 845 - "connectionTitle": "Slack webhook connection", 846 - "disconnect": "Disconnect", 842 + "connect": "Σύνδεση Slack", 843 + "connected": "Συνδεδεμένο", 844 + "connectionHint": "Επικολλήστε ένα URL εισερχόμενου webhook του Slack και επιλέξτε ποια γεγονότα εργασιών θα δημοσιεύονται.", 845 + "connectionTitle": "Σύνδεση webhook Slack", 846 + "disconnect": "Αποσύνδεση", 847 847 "events": { 848 - "taskCommentCreated": "New comments", 849 - "taskCreated": "New tasks", 850 - "taskDescriptionChanged": "Description changes", 851 - "taskPriorityChanged": "Priority changes", 852 - "taskStatusChanged": "Status changes", 853 - "taskTitleChanged": "Title changes" 848 + "taskCommentCreated": "Νέα σχόλια", 849 + "taskCreated": "Νέες εργασίες", 850 + "taskDescriptionChanged": "Αλλαγές περιγραφής", 851 + "taskPriorityChanged": "Αλλαγές προτεραιότητας", 852 + "taskStatusChanged": "Αλλαγές κατάστασης", 853 + "taskTitleChanged": "Αλλαγές τίτλου" 854 854 }, 855 - "eventsHint": "Choose which project changes should post to Slack.", 856 - "eventsTitle": "Event notifications", 857 - "paused": "Paused", 858 - "saveChanges": "Save changes", 855 + "eventsHint": "Επιλέξτε ποιες αλλαγές έργου θα δημοσιεύονται στο Slack.", 856 + "eventsTitle": "Ειδοποιήσεις γεγονότων", 857 + "paused": "Σε παύση", 858 + "saveChanges": "Αποθήκευση αλλαγών", 859 859 "toast": { 860 - "disabled": "Slack notifications paused", 861 - "enabled": "Slack notifications enabled", 862 - "removed": "Slack integration removed successfully", 863 - "removeError": "Failed to remove Slack integration", 864 - "saved": "Slack integration saved successfully", 865 - "saveError": "Failed to save Slack integration", 866 - "updateError": "Failed to update Slack integration" 860 + "disabled": "Οι ειδοποιήσεις Slack τέθηκαν σε παύση", 861 + "enabled": "Οι ειδοποιήσεις Slack ενεργοποιήθηκαν", 862 + "removed": "Η ενσωμάτωση Slack αφαιρέθηκε με επιτυχία", 863 + "removeError": "Αποτυχία αφαίρεσης της ενσωμάτωσης Slack", 864 + "saved": "Η ενσωμάτωση Slack αποθηκεύτηκε με επιτυχία", 865 + "saveError": "Αποτυχία αποθήκευσης της ενσωμάτωσης Slack", 866 + "updateError": "Αποτυχία ενημέρωσης της ενσωμάτωσης Slack" 867 867 }, 868 - "update": "Update Slack", 868 + "update": "Ενημέρωση Slack", 869 869 "validation": { 870 - "webhookInvalid": "Enter a valid Slack webhook URL" 870 + "webhookInvalid": "Εισαγάγετε ένα έγκυρο URL webhook Slack" 871 871 }, 872 - "webhookHint": "Create an Incoming Webhook in Slack and paste the generated URL here.", 873 - "webhookLabel": "Incoming webhook URL", 872 + "webhookHint": "Δημιουργήστε ένα Incoming Webhook στο Slack και επικολλήστε εδώ το παραγόμενο URL.", 873 + "webhookLabel": "URL εισερχόμενου webhook", 874 874 "webhookPlaceholder": "https://hooks.slack.com/services/..." 875 875 } 876 876 },