Configure the invitation mailbox in the UI, not the environment

Putting IMAP settings in environment variables was my call to make and it was
the wrong one: it meant editing a file on the server and restarting to change a
folder name, and it was never offered as a choice.

The mailbox is now a row in mailbox_accounts, edited under Settings → Admin:
host, user, password, port, folder, interval, on/off. Administrators only — it
is a shared forwarding target, not a per-user account. The password is never
returned by any endpoint, the same as the CalDAV account routes, and leaving
the field empty on edit keeps the stored one so changing a port does not mean
retyping it. The panel shows when the mailbox was last read and what went wrong
if it did, because a mailbox that quietly stopped working looks exactly like
one that has no mail.

Worth stating plainly: the password now lives in the database, in clear text,
like the CalDAV, Google and Home Assistant credentials beside it. Unlike those
three, an IMAP password opens an entire mailbox.

The scheduler job is registered unconditionally now — whether a mailbox exists
can change without a restart, and fetch_invites returns immediately when none
does. test_scheduler knows about it.

Also adds the requests list to the sidebar with accept and decline, 26
translation keys across all ten languages, and tests covering that the password
is never returned, that an edit without one keeps it, and that a disabled
mailbox counts as unconfigured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-29 17:31:56 +01:00
co-authored by Claude Opus 5
parent 9bf7d3797d
commit a67602dbc2
10 changed files with 737 additions and 46 deletions
+145 -1
View File
@@ -179,6 +179,7 @@ export async function initCalendar() {
bindSwipeNavigation();
handleHAOAuthReturn();
loadGroups();
loadInvites();
try {
await fetchAndRender();
} catch (e) {
@@ -3717,7 +3718,7 @@ function populateSettings() {
const user = JSON.parse(localStorage.getItem('user') || '{}');
const usersNavBtn = document.getElementById('settings-nav-users');
if (usersNavBtn) usersNavBtn.classList.toggle('hidden', !user.is_admin);
if (user.is_admin) { loadUsers(); loadAdminPanel(); }
if (user.is_admin) { loadUsers(); loadAdminPanel(); loadMailbox(); }
// Activate panel from URL or fall back to first visible
const urlTab = readUrlState().stab;
@@ -4772,6 +4773,148 @@ function renderCalendarTable() {
});
}
// ── Einladungs-Postfach (nur Admin) ──────────────────────
function mailboxFields() {
return {
enabled: document.getElementById('mb-enabled').checked,
imap_host: document.getElementById('mb-imap-host').value.trim(),
imap_user: document.getElementById('mb-imap-user').value.trim(),
imap_password: document.getElementById('mb-imap-password').value || null,
imap_port: parseInt(document.getElementById('mb-imap-port').value) || 993,
imap_ssl: document.getElementById('mb-imap-ssl').checked,
imap_folder: document.getElementById('mb-imap-folder').value.trim() || 'INBOX',
poll_minutes: parseInt(document.getElementById('mb-poll-minutes').value) || 5,
};
}
function renderMailboxStatus(cfg) {
const el = document.getElementById('mailbox-status');
if (!el) return;
if (!cfg) { el.textContent = t('invites_mailbox_none'); return; }
// A mailbox that stopped working has to say so, or invitations simply stop
// arriving and nothing explains why.
if (cfg.last_error) {
el.textContent = `${t('invites_last_error')}: ${cfg.last_error}`;
el.style.color = 'var(--danger, #ea4335)';
} else {
el.textContent = cfg.last_run
? `${t('invites_last_run')}: ${new Date(cfg.last_run).toLocaleString()}`
: t('invites_never_run');
el.style.color = '';
}
}
async function loadMailbox() {
const host = document.getElementById('mb-imap-host');
if (!host) return;
let cfg = null;
try { cfg = await api.get('/invites/mailbox'); } catch { return; }
renderMailboxStatus(cfg);
if (!cfg) return;
host.value = cfg.imap_host || '';
document.getElementById('mb-imap-user').value = cfg.imap_user || '';
document.getElementById('mb-imap-port').value = cfg.imap_port || 993;
document.getElementById('mb-imap-folder').value = cfg.imap_folder || 'INBOX';
document.getElementById('mb-poll-minutes').value = cfg.poll_minutes || 5;
document.getElementById('mb-imap-ssl').checked = cfg.imap_ssl !== false;
document.getElementById('mb-enabled').checked = cfg.enabled !== false;
// Left blank on purpose: an empty field means "keep what is stored".
document.getElementById('mb-imap-password').value = '';
}
function wireMailbox() {
const save = document.getElementById('mb-save');
if (!save) return;
save.addEventListener('click', async () => {
try {
const cfg = await api.put('/invites/mailbox', mailboxFields());
document.getElementById('mb-imap-password').value = '';
renderMailboxStatus(cfg);
showToast(t('settings_saved'));
} catch (e) { showToast(e.message, true); }
});
document.getElementById('mb-poll').addEventListener('click', async () => {
try {
const r = await api.post('/invites/poll', {});
showToast(r.last_error ? r.last_error : t('invites_polled'), !!r.last_error);
await loadMailbox();
await loadInvites();
} catch (e) { showToast(e.message, true); }
});
document.getElementById('mb-delete').addEventListener('click', async () => {
if (!await confirmModal(t('invites_mailbox_remove_confirm'), { danger: true })) return;
try {
await api.delete('/invites/mailbox');
document.querySelectorAll('#settings-panel-users input[id^="mb-"]').forEach(el => {
if (el.type === 'checkbox') el.checked = true; else el.value = '';
});
renderMailboxStatus(null);
showToast(t('settings_saved'));
} catch (e) { showToast(e.message, true); }
});
}
// ── Anfragen ─────────────────────────────────────────────
async function loadInvites() {
const box = document.getElementById('invite-list');
if (!box) return;
let rows = [];
try { rows = await api.get('/invites'); } catch { rows = []; }
box.classList.toggle('hidden', rows.length === 0);
const items = document.getElementById('invite-list-items');
items.innerHTML = '';
for (const inv of rows) {
const row = document.createElement('div');
row.className = 'cal-item';
const name = document.createElement('span');
name.className = 'cal-item-name';
// textContent throughout: this text came from a stranger's mail.
name.textContent = inv.title;
name.title = `${fmtInviteWhen(inv)}${inv.organizer_name ? ' · ' + inv.organizer_name : ''}`;
row.appendChild(name);
if (inv.status === 'cancelled') {
const tag = document.createElement('span');
tag.className = 'cal-badge';
tag.textContent = t('invite_cancelled');
row.appendChild(tag);
row.appendChild(inviteBtn(inv, 'dismiss', '✕'));
} else {
row.appendChild(inviteBtn(inv, 'accept', '✓'));
row.appendChild(inviteBtn(inv, 'decline', '✕'));
}
items.appendChild(row);
}
}
function fmtInviteWhen(inv) {
try {
const d = new Date(inv.start);
return inv.all_day ? d.toLocaleDateString() : d.toLocaleString();
} catch { return inv.start; }
}
function inviteBtn(inv, action, label) {
const b = document.createElement('button');
b.className = 'icon-btn mini-btn';
b.textContent = label;
b.title = t(`invite_${action}`);
b.addEventListener('click', async () => {
try {
if (action === 'dismiss') {
await api.delete(`/invites/${inv.id}`);
} else {
await api.post(`/invites/${inv.id}/respond`, { action });
showToast(t(action === 'accept' ? 'invite_accepted' : 'invite_declined'));
}
await loadInvites();
if (action === 'accept') fetchAndRender(true);
} catch (e) { showToast(e.message, true); }
});
return b;
}
async function loadUsers() {
try {
const users = await api.get('/users/');
@@ -4992,6 +5135,7 @@ function bindSettingsModal() {
// Admin panel (default-theme editor + branding) — buttons bound once.
bindAdminPanel();
wireMailbox();
// Theme export / import (client-side .theme files).
const themeExportBtn = document.getElementById('cfg-theme-export');
+260
View File
@@ -1,5 +1,31 @@
const translations = {
de: {
invites_title: 'Anfragen',
invite_accept: 'Annehmen',
invite_decline: 'Ablehnen',
invite_dismiss: 'Entfernen',
invite_cancelled: 'Zurückgezogen',
invite_accepted: 'Einladung angenommen',
invite_declined: 'Einladung abgelehnt',
invites_mailbox: 'Einladungs-Postfach',
invites_mailbox_desc: 'Ein Postfach, in dem Termin-Einladungen ankommen. Sie erscheinen als Anfrage und werden erst zu Terminen, wenn du sie annimmst. Zugeordnet wird über die Teilnehmer-Adresse der Einladung — trag die Adresse, unter der du eingeladen wirst, in deinem Profil ein.',
invites_mailbox_none: 'Kein Postfach konfiguriert',
invites_mailbox_remove: 'Postfach entfernen',
invites_mailbox_remove_confirm: 'Postfach wirklich entfernen? Bereits empfangene Anfragen bleiben erhalten.',
invites_imap_host: 'IMAP-Server',
invites_imap_user: 'Benutzername',
invites_imap_password: 'Passwort',
invites_imap_port: 'Port',
invites_imap_folder: 'Ordner',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Abrufintervall (Minuten)',
invites_poll_now: 'Jetzt abrufen',
invites_polled: 'Postfach abgerufen',
invites_password_keep: 'Unverändert lassen',
invites_last_run: 'Zuletzt abgerufen',
invites_last_error: 'Letzter Fehler',
invites_never_run: 'Noch nicht abgerufen',
// Common
save: 'Speichern', cancel: 'Abbrechen', close: 'Schliessen',
create: 'Erstellen', delete: 'Löschen', edit: 'Bearbeiten',
@@ -433,6 +459,32 @@ const translations = {
},
ch: {
invites_title: 'Aafrage',
invite_accept: 'Aanäh',
invite_decline: 'Ablehne',
invite_dismiss: 'Entfärne',
invite_cancelled: 'Zruggzoge',
invite_accepted: 'Iiladig aagnoh',
invite_declined: 'Iiladig abglehnt',
invites_mailbox: 'Iiladigs-Poschtfach',
invites_mailbox_desc: 'Es Poschtfach, wo Termin-Iiladige akömed. Sie erschiined als Aafrag und wärded erscht zu Termin, wänn du sie aanimmsch. Zuegordnet wird über d Teilnehmer-Adräss vo dr Iiladig — trag d Adräss, unter dere du iiglade wirsch, i dim Profil ii.',
invites_mailbox_none: 'Kes Poschtfach konfiguriert',
invites_mailbox_remove: 'Poschtfach entfärne',
invites_mailbox_remove_confirm: 'Poschtfach würkli entfärne? Scho empfangeni Aafrage blibed erhalte.',
invites_imap_host: 'IMAP-Server',
invites_imap_user: 'Benutzername',
invites_imap_password: 'Passwort',
invites_imap_port: 'Port',
invites_imap_folder: 'Ordner',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Abruefintervall (Minute)',
invites_poll_now: 'Jetz abruefe',
invites_polled: 'Poschtfach abgruefe',
invites_password_keep: 'Unverändert la',
invites_last_run: 'Zletscht abgruefe',
invites_last_error: 'Letschte Fähler',
invites_never_run: 'No nid abgruefe',
// Common
save: 'Speicherä', cancel: 'Abbräche', close: 'Schlüsse',
create: 'Erstellä', delete: 'Löschä', edit: 'Bearbeitä',
@@ -828,6 +880,32 @@ const translations = {
},
en: {
invites_title: 'Requests',
invite_accept: 'Accept',
invite_decline: 'Decline',
invite_dismiss: 'Dismiss',
invite_cancelled: 'Withdrawn',
invite_accepted: 'Invitation accepted',
invite_declined: 'Invitation declined',
invites_mailbox: 'Invitation mailbox',
invites_mailbox_desc: 'A mailbox that invitations arrive in. They show up as requests and only become events once you accept them. An invitation is matched by its attendee address — put the address people invite you under in your profile.',
invites_mailbox_none: 'No mailbox configured',
invites_mailbox_remove: 'Remove mailbox',
invites_mailbox_remove_confirm: 'Remove the mailbox? Requests already received are kept.',
invites_imap_host: 'IMAP server',
invites_imap_user: 'Username',
invites_imap_password: 'Password',
invites_imap_port: 'Port',
invites_imap_folder: 'Folder',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Enabled',
invites_poll_minutes: 'Check every (minutes)',
invites_poll_now: 'Check now',
invites_polled: 'Mailbox checked',
invites_password_keep: 'Leave unchanged',
invites_last_run: 'Last checked',
invites_last_error: 'Last error',
invites_never_run: 'Not checked yet',
// Common
save: 'Save', cancel: 'Cancel', close: 'Close',
create: 'Create', delete: 'Delete', edit: 'Edit',
@@ -1261,6 +1339,32 @@ const translations = {
},
fr: {
invites_title: 'Demandes',
invite_accept: 'Accepter',
invite_decline: 'Refuser',
invite_dismiss: 'Retirer',
invite_cancelled: 'Annulée',
invite_accepted: 'Invitation acceptée',
invite_declined: 'Invitation refusée',
invites_mailbox: 'Boîte aux lettres des invitations',
invites_mailbox_desc: "Une boîte dans laquelle arrivent les invitations. Elles apparaissent comme demandes et ne deviennent des événements qu'une fois acceptées. L'attribution se fait par l'adresse du participant — indiquez dans votre profil l'adresse sous laquelle on vous invite.",
invites_mailbox_none: 'Aucune boîte configurée',
invites_mailbox_remove: 'Supprimer la boîte',
invites_mailbox_remove_confirm: 'Supprimer la boîte ? Les demandes déjà reçues sont conservées.',
invites_imap_host: 'Serveur IMAP',
invites_imap_user: "Nom d'utilisateur",
invites_imap_password: 'Mot de passe',
invites_imap_port: 'Port',
invites_imap_folder: 'Dossier',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Activé',
invites_poll_minutes: 'Intervalle (minutes)',
invites_poll_now: 'Relever maintenant',
invites_polled: 'Boîte relevée',
invites_password_keep: 'Laisser inchangé',
invites_last_run: 'Dernier relevé',
invites_last_error: 'Dernière erreur',
invites_never_run: 'Jamais relevée',
// Common
save: 'Enregistrer', cancel: 'Annuler', close: 'Fermer',
create: 'Créer', delete: 'Supprimer', edit: 'Modifier',
@@ -1656,6 +1760,32 @@ const translations = {
},
it: {
invites_title: 'Richieste',
invite_accept: 'Accetta',
invite_decline: 'Rifiuta',
invite_dismiss: 'Rimuovi',
invite_cancelled: 'Ritirata',
invite_accepted: 'Invito accettato',
invite_declined: 'Invito rifiutato',
invites_mailbox: 'Casella degli inviti',
invites_mailbox_desc: "Una casella in cui arrivano gli inviti. Compaiono come richieste e diventano eventi solo quando li accetti. L'abbinamento avviene tramite l'indirizzo del partecipante — inserisci nel profilo l'indirizzo con cui vieni invitato.",
invites_mailbox_none: 'Nessuna casella configurata',
invites_mailbox_remove: 'Rimuovi casella',
invites_mailbox_remove_confirm: 'Rimuovere la casella? Le richieste già ricevute restano.',
invites_imap_host: 'Server IMAP',
invites_imap_user: 'Nome utente',
invites_imap_password: 'Password',
invites_imap_port: 'Porta',
invites_imap_folder: 'Cartella',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Attiva',
invites_poll_minutes: 'Intervallo (minuti)',
invites_poll_now: 'Controlla ora',
invites_polled: 'Casella controllata',
invites_password_keep: 'Lascia invariato',
invites_last_run: 'Ultimo controllo',
invites_last_error: 'Ultimo errore',
invites_never_run: 'Mai controllata',
// Common
save: 'Salva', cancel: 'Annulla', close: 'Chiudi',
create: 'Crea', delete: 'Elimina', edit: 'Modifica',
@@ -2051,6 +2181,32 @@ const translations = {
},
nl: {
invites_title: 'Verzoeken',
invite_accept: 'Accepteren',
invite_decline: 'Weigeren',
invite_dismiss: 'Verwijderen',
invite_cancelled: 'Ingetrokken',
invite_accepted: 'Uitnodiging geaccepteerd',
invite_declined: 'Uitnodiging geweigerd',
invites_mailbox: 'Uitnodigingen-postbus',
invites_mailbox_desc: 'Een postbus waarin uitnodigingen binnenkomen. Ze verschijnen als verzoek en worden pas afspraken zodra je ze accepteert. Toewijzing gebeurt via het deelnemersadres — zet het adres waarop je wordt uitgenodigd in je profiel.',
invites_mailbox_none: 'Geen postbus ingesteld',
invites_mailbox_remove: 'Postbus verwijderen',
invites_mailbox_remove_confirm: 'Postbus verwijderen? Reeds ontvangen verzoeken blijven staan.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Gebruikersnaam',
invites_imap_password: 'Wachtwoord',
invites_imap_port: 'Poort',
invites_imap_folder: 'Map',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Actief',
invites_poll_minutes: 'Interval (minuten)',
invites_poll_now: 'Nu ophalen',
invites_polled: 'Postbus opgehaald',
invites_password_keep: 'Ongewijzigd laten',
invites_last_run: 'Laatst opgehaald',
invites_last_error: 'Laatste fout',
invites_never_run: 'Nog niet opgehaald',
// Common
save: 'Opslaan', cancel: 'Annuleren', close: 'Sluiten',
create: 'Aanmaken', delete: 'Verwijderen', edit: 'Bewerken',
@@ -2446,6 +2602,32 @@ const translations = {
},
da: {
invites_title: 'Anmodninger',
invite_accept: 'Accepter',
invite_decline: 'Afvis',
invite_dismiss: 'Fjern',
invite_cancelled: 'Trukket tilbage',
invite_accepted: 'Invitation accepteret',
invite_declined: 'Invitation afvist',
invites_mailbox: 'Postkasse til invitationer',
invites_mailbox_desc: 'En postkasse hvor invitationer ankommer. De vises som anmodninger og bliver først til begivenheder, når du accepterer dem. Tilknytning sker via deltageradressen — angiv i din profil den adresse, du bliver inviteret på.',
invites_mailbox_none: 'Ingen postkasse konfigureret',
invites_mailbox_remove: 'Fjern postkasse',
invites_mailbox_remove_confirm: 'Fjern postkassen? Allerede modtagne anmodninger bevares.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Brugernavn',
invites_imap_password: 'Adgangskode',
invites_imap_port: 'Port',
invites_imap_folder: 'Mappe',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Interval (minutter)',
invites_poll_now: 'Hent nu',
invites_polled: 'Postkasse hentet',
invites_password_keep: 'Lad være uændret',
invites_last_run: 'Sidst hentet',
invites_last_error: 'Seneste fejl',
invites_never_run: 'Endnu ikke hentet',
// Common
save: 'Gem', cancel: 'Annullér', close: 'Luk',
create: 'Opret', delete: 'Slet', edit: 'Redigér',
@@ -2841,6 +3023,32 @@ const translations = {
},
no: {
invites_title: 'Forespørsler',
invite_accept: 'Godta',
invite_decline: 'Avslå',
invite_dismiss: 'Fjern',
invite_cancelled: 'Trukket tilbake',
invite_accepted: 'Invitasjon godtatt',
invite_declined: 'Invitasjon avslått',
invites_mailbox: 'Postkasse for invitasjoner',
invites_mailbox_desc: 'En postkasse der invitasjoner kommer inn. De vises som forespørsler og blir først avtaler når du godtar dem. Tilknytning skjer via deltakeradressen — legg inn adressen du blir invitert på i profilen din.',
invites_mailbox_none: 'Ingen postkasse satt opp',
invites_mailbox_remove: 'Fjern postkasse',
invites_mailbox_remove_confirm: 'Fjerne postkassen? Allerede mottatte forespørsler beholdes.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Brukernavn',
invites_imap_password: 'Passord',
invites_imap_port: 'Port',
invites_imap_folder: 'Mappe',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Intervall (minutter)',
invites_poll_now: 'Hent nå',
invites_polled: 'Postkasse hentet',
invites_password_keep: 'La stå uendret',
invites_last_run: 'Sist hentet',
invites_last_error: 'Siste feil',
invites_never_run: 'Ikke hentet ennå',
// Common
save: 'Lagre', cancel: 'Avbryt', close: 'Lukk',
create: 'Opprett', delete: 'Slett', edit: 'Rediger',
@@ -3236,6 +3444,32 @@ const translations = {
},
sv: {
invites_title: 'Förfrågningar',
invite_accept: 'Acceptera',
invite_decline: 'Avböj',
invite_dismiss: 'Ta bort',
invite_cancelled: 'Återkallad',
invite_accepted: 'Inbjudan accepterad',
invite_declined: 'Inbjudan avböjd',
invites_mailbox: 'Postlåda för inbjudningar',
invites_mailbox_desc: 'En postlåda dit inbjudningar kommer. De visas som förfrågningar och blir händelser först när du accepterar dem. Kopplingen sker via deltagaradressen — ange i din profil den adress du blir inbjuden på.',
invites_mailbox_none: 'Ingen postlåda konfigurerad',
invites_mailbox_remove: 'Ta bort postlåda',
invites_mailbox_remove_confirm: 'Ta bort postlådan? Redan mottagna förfrågningar behålls.',
invites_imap_host: 'IMAP-server',
invites_imap_user: 'Användarnamn',
invites_imap_password: 'Lösenord',
invites_imap_port: 'Port',
invites_imap_folder: 'Mapp',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Aktiv',
invites_poll_minutes: 'Intervall (minuter)',
invites_poll_now: 'Hämta nu',
invites_polled: 'Postlådan hämtad',
invites_password_keep: 'Lämna oförändrat',
invites_last_run: 'Senast hämtad',
invites_last_error: 'Senaste fel',
invites_never_run: 'Inte hämtad ännu',
// Common
save: 'Spara', cancel: 'Avbryt', close: 'Stäng',
create: 'Skapa', delete: 'Radera', edit: 'Redigera',
@@ -3631,6 +3865,32 @@ const translations = {
},
fi: {
invites_title: 'Pyynnöt',
invite_accept: 'Hyväksy',
invite_decline: 'Hylkää',
invite_dismiss: 'Poista',
invite_cancelled: 'Peruttu',
invite_accepted: 'Kutsu hyväksytty',
invite_declined: 'Kutsu hylätty',
invites_mailbox: 'Kutsujen postilaatikko',
invites_mailbox_desc: 'Postilaatikko, johon kutsut saapuvat. Ne näkyvät pyyntöinä ja muuttuvat tapahtumiksi vasta kun hyväksyt ne. Kohdistus tehdään osallistujan osoitteen perusteella — lisää profiiliisi osoite, jolla sinut kutsutaan.',
invites_mailbox_none: 'Postilaatikkoa ei määritetty',
invites_mailbox_remove: 'Poista postilaatikko',
invites_mailbox_remove_confirm: 'Poistetaanko postilaatikko? Jo vastaanotetut pyynnöt säilyvät.',
invites_imap_host: 'IMAP-palvelin',
invites_imap_user: 'Käyttäjätunnus',
invites_imap_password: 'Salasana',
invites_imap_port: 'Portti',
invites_imap_folder: 'Kansio',
invites_imap_ssl: 'SSL/TLS',
invites_enabled: 'Käytössä',
invites_poll_minutes: 'Hakuväli (minuuttia)',
invites_poll_now: 'Hae nyt',
invites_polled: 'Postilaatikko haettu',
invites_password_keep: 'Jätä ennalleen',
invites_last_run: 'Viimeksi haettu',
invites_last_error: 'Viimeisin virhe',
invites_never_run: 'Ei vielä haettu',
// Common
save: 'Tallenna', cancel: 'Peruuta', close: 'Sulje',
create: 'Luo', delete: 'Poista', edit: 'Muokkaa',