From df6a56b19522db6017f7f5333e40335266cb0f9f Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Tue, 9 Jun 2026 00:26:57 +0000 Subject: [PATCH 01/45] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/ar-SA.json | 4 +--- frontend/src/i18n/lang/bg-BG.json | 4 +--- frontend/src/i18n/lang/cs-CZ.json | 2 -- frontend/src/i18n/lang/de-DE.json | 2 -- frontend/src/i18n/lang/de-swiss.json | 2 -- frontend/src/i18n/lang/el-GR.json | 2 -- frontend/src/i18n/lang/es-ES.json | 4 +--- frontend/src/i18n/lang/fa-IR.json | 2 -- frontend/src/i18n/lang/fi-FI.json | 4 +--- frontend/src/i18n/lang/fr-FR.json | 2 -- frontend/src/i18n/lang/he-IL.json | 4 +--- frontend/src/i18n/lang/hr-HR.json | 6 ++---- frontend/src/i18n/lang/hu-HU.json | 4 +--- frontend/src/i18n/lang/it-IT.json | 2 -- frontend/src/i18n/lang/ja-JP.json | 2 -- frontend/src/i18n/lang/ko-KR.json | 4 +--- frontend/src/i18n/lang/lt-LT.json | 4 +--- frontend/src/i18n/lang/nl-NL.json | 2 -- frontend/src/i18n/lang/no-NO.json | 2 -- frontend/src/i18n/lang/pl-PL.json | 4 +--- frontend/src/i18n/lang/pt-BR.json | 4 +--- frontend/src/i18n/lang/pt-PT.json | 2 -- frontend/src/i18n/lang/ru-RU.json | 2 -- frontend/src/i18n/lang/sl-SI.json | 4 +--- frontend/src/i18n/lang/sv-SE.json | 2 -- frontend/src/i18n/lang/tr-TR.json | 2 -- frontend/src/i18n/lang/uk-UA.json | 2 -- frontend/src/i18n/lang/vi-VN.json | 4 +--- frontend/src/i18n/lang/zh-CN.json | 2 -- frontend/src/i18n/lang/zh-TW.json | 2 -- 30 files changed, 14 insertions(+), 74 deletions(-) diff --git a/frontend/src/i18n/lang/ar-SA.json b/frontend/src/i18n/lang/ar-SA.json index f9338b33a..26e0d5ef2 100644 --- a/frontend/src/i18n/lang/ar-SA.json +++ b/frontend/src/i18n/lang/ar-SA.json @@ -284,8 +284,7 @@ "default": "افتراضي", "month": "شهر", "day": "يوم", - "hour": "ساعة", - "range": "نطاق التاريخ" + "hour": "ساعة" }, "table": { "title": "جدول", @@ -294,7 +293,6 @@ "kanban": { "title": "Kanban", "limit": "الحد: {limit}", - "noLimit": "غير محدد", "doneBucket": "حافظة المهام المكتملة", "doneBucketHint": "سيتم تلقائياً وضع علامة مكتمل على جميع المهام التي تم نقلها إلى هذه الحافظة.", "doneBucketHintExtended": "سيتم وضع علامة مكتمل على جميع المهام التي تم نقلها إلى حافظة المهام المكتملة. كما سيتم نقل جميع المهام المكتملة من أماكن أخرى.", diff --git a/frontend/src/i18n/lang/bg-BG.json b/frontend/src/i18n/lang/bg-BG.json index 28b2c292f..0a4458c07 100644 --- a/frontend/src/i18n/lang/bg-BG.json +++ b/frontend/src/i18n/lang/bg-BG.json @@ -314,8 +314,7 @@ "default": "По подразбиране", "month": "Месец", "day": "Ден", - "hour": "Час", - "range": "Времеви диапазон" + "hour": "Час" }, "table": { "title": "Таблица", @@ -324,7 +323,6 @@ "kanban": { "title": "Канбан", "limit": "Лимит: {limit}", - "noLimit": "Не е зададен", "doneBucket": "Колона за завършени", "doneBucketHint": "Всички задачи, преместени в тази колона, автоматично ще бъдат маркирани като завършени.", "doneBucketHintExtended": "Всички задачи, преместени в колоната за завършени, ще бъдат автоматично маркирани като завършени. Всички задачи, маркирани като завършени от другаде, също ще бъдат преместени тук.", diff --git a/frontend/src/i18n/lang/cs-CZ.json b/frontend/src/i18n/lang/cs-CZ.json index b6ca3a20b..6a8d3c523 100644 --- a/frontend/src/i18n/lang/cs-CZ.json +++ b/frontend/src/i18n/lang/cs-CZ.json @@ -383,7 +383,6 @@ "month": "Měsíc", "day": "Den", "hour": "Hodina", - "range": "Časové období", "chartLabel": "Projektový Ganttův diagram", "taskBarsForRow": "Chlívky pro řádek {rowId}", "taskBarLabel": "Úkol: {task}. Od {startDate} do {endDate}. {dateType}. Klikněte pro úpravu, přetáhněte pro přesun.", @@ -412,7 +411,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nenastaveno", "doneBucket": "Sloupec \"Hotovo\"", "doneBucketHint": "Všechny úkoly přesunuté do tohoto sloupce budou automaticky označeny jako dokončené.", "doneBucketHintExtended": "Všechny úkoly přesunuté do sloupce \"Hotovo\" budou označeny jako dokončené automaticky. Všechny úkoly označené jako dokončené jinde sem budou přesunuty také.", diff --git a/frontend/src/i18n/lang/de-DE.json b/frontend/src/i18n/lang/de-DE.json index bf093f624..8a71cefb1 100644 --- a/frontend/src/i18n/lang/de-DE.json +++ b/frontend/src/i18n/lang/de-DE.json @@ -470,7 +470,6 @@ "month": "Monat", "day": "Tag", "hour": "Stunde", - "range": "Zeitraum", "chartLabel": "Projekt Gantt-Diagramm", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", @@ -499,7 +498,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nicht gesetzt", "doneBucket": "Erledigt Spalte", "doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.", diff --git a/frontend/src/i18n/lang/de-swiss.json b/frontend/src/i18n/lang/de-swiss.json index 541d992f1..a76780342 100644 --- a/frontend/src/i18n/lang/de-swiss.json +++ b/frontend/src/i18n/lang/de-swiss.json @@ -470,7 +470,6 @@ "month": "Monat", "day": "Tag", "hour": "Stunde", - "range": "Zeitraum", "chartLabel": "Projekt Gantt-Diagramm", "taskBarsForRow": "Aufgabenleisten für Zeile {rowId}", "taskBarLabel": "Aufgabe: {task}. Von {startDate} bis {endDate}. {dateType}. Klicke zum Bearbeiten, ziehe zum Verschieben.", @@ -499,7 +498,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nicht gesetzt", "doneBucket": "Erledigt Spalte", "doneBucketHint": "Alle Aufgaben, die in diese Spalte verschoben werden, werden automatisch als erledigt markiert.", "doneBucketHintExtended": "Alle Aufgaben, die in die Erledigt Spalte verschoben wurden, werden automatisch als erledigt markiert. Aufgaben, die in einer anderen Spalte als Erledigt markiert wurden, werden auch in diese Spalte verschoben.", diff --git a/frontend/src/i18n/lang/el-GR.json b/frontend/src/i18n/lang/el-GR.json index 5eee25a79..4848afad7 100644 --- a/frontend/src/i18n/lang/el-GR.json +++ b/frontend/src/i18n/lang/el-GR.json @@ -470,7 +470,6 @@ "month": "Μήνας", "day": "Ημέρα", "hour": "Ώρα", - "range": "Εύρος Ημερομηνιών", "chartLabel": "Γράφημα Gantt Έργου", "taskBarsForRow": "Μπάρες εργασίας για τη γραμμή {rowId}", "taskBarLabel": "Εργασία: {task}. Από {startDate} έως {endDate}. {dateType}. Κάντε κλικ για επεξεργασία, σύρετε για μετακίνηση.", @@ -499,7 +498,6 @@ "kanban": { "title": "Kanban", "limit": "Όριο: {limit}", - "noLimit": "Δεν έχει οριστεί", "doneBucket": "Κάδος για ολοκληρωμένα", "doneBucketHint": "Όλες οι εργασίες που μετακινούνται σε αυτόν τον κάδο θα σημειώνονται αυτόματα ως ολοκληρωμένες.", "doneBucketHintExtended": "Όλες οι εργασίες που μετακινούνται στον κάδο των ολοκληρωμένων θα σημειώνονται αυτόματα ως ολοκληρωμένες. Όλες οι εργασίες που σημειώνονται ως ολοκληρωμένες από αλλού επίσης θα μετακινηθούν.", diff --git a/frontend/src/i18n/lang/es-ES.json b/frontend/src/i18n/lang/es-ES.json index 962beb5f2..98e9f78e6 100644 --- a/frontend/src/i18n/lang/es-ES.json +++ b/frontend/src/i18n/lang/es-ES.json @@ -251,8 +251,7 @@ "default": "Predeterminado", "month": "Mes", "day": "Día", - "hour": "Hora", - "range": "Rango de fechas" + "hour": "Hora" }, "table": { "title": "Tabla", @@ -261,7 +260,6 @@ "kanban": { "title": "Kanban", "limit": "Límite: {limit}", - "noLimit": "No Establecido", "doneBucket": "Contenedor completado", "doneBucketHint": "Todas las tareas movidas a este contenedor se marcarán automáticamente como finalizadas.", "doneBucketHintExtended": "Todas las tareas movidas al contenedor completado se marcarán como finalizadas automáticamente. Todas las tareas marcadas como finalizadas desde otro lugar también se moverán.", diff --git a/frontend/src/i18n/lang/fa-IR.json b/frontend/src/i18n/lang/fa-IR.json index 07198d732..fdc0681c7 100644 --- a/frontend/src/i18n/lang/fa-IR.json +++ b/frontend/src/i18n/lang/fa-IR.json @@ -463,7 +463,6 @@ "month": "ماه", "day": "روز", "hour": "ساعت", - "range": "محدوده تاریخ", "chartLabel": "نمودار گانت پروژه", "taskBarsForRow": "نوارهای وظیفه برای ردیف {rowId}", "taskBarLabel": "وظیفه: {task}. از {startDate} تا {endDate}. {dateType}. برای ویرایش کلیک کنید، برای جابجایی بکشید.", @@ -492,7 +491,6 @@ "kanban": { "title": "کانبان", "limit": "محدودیت: {limit}", - "noLimit": "تنظیم نشده", "doneBucket": "سطل انجام شده", "doneBucketHint": "تمام وظایفی که به این سطل منتقل شوند به طور خودکار به عنوان انجام شده علامت‌گذاری می‌شوند.", "doneBucketHintExtended": "تمام وظایفی که به سطل انجام شده منتقل شوند به طور خودکار علامت‌گذاری می‌شوند. همچنین تمام وظایفی که از جای دیگر به عنوان انجام شده علامت‌گذاری شوند نیز به اینجا منتقل خواهند شد.", diff --git a/frontend/src/i18n/lang/fi-FI.json b/frontend/src/i18n/lang/fi-FI.json index 5ceb0be11..90e1e99c6 100644 --- a/frontend/src/i18n/lang/fi-FI.json +++ b/frontend/src/i18n/lang/fi-FI.json @@ -347,8 +347,7 @@ "default": "Oletus", "month": "Kuukausi", "day": "Päivä", - "hour": "Tunti", - "range": "Ajanjakso" + "hour": "Tunti" }, "table": { "title": "Taulukko", @@ -357,7 +356,6 @@ "kanban": { "title": "Kanban", "limit": "Raja: {limit}", - "noLimit": "Ei Asetettu", "doneBucket": "Valmiit sarake", "doneBucketHint": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi.", "doneBucketHintExtended": "Kaikki tähän sarakkeeseen lisätyt tehtävät merkitään automaattisesti valmiiksi. Muualla valmiiksi merkityt tehtävät siirretään myös.", diff --git a/frontend/src/i18n/lang/fr-FR.json b/frontend/src/i18n/lang/fr-FR.json index 7bcb472c7..fe9357062 100644 --- a/frontend/src/i18n/lang/fr-FR.json +++ b/frontend/src/i18n/lang/fr-FR.json @@ -346,7 +346,6 @@ "month": "Mois", "day": "Jour", "hour": "Heure", - "range": "Intervalle", "chartLabel": "Diagramme de Gantt du projet", "taskBarsForRow": "Barres de tâches pour la ligne {rowId}", "taskBarLabel": "Tâche : {task}. De {startDate} à {endDate}. {dateType}. Cliquez pour modifier, faites glisser pour déplacer.", @@ -370,7 +369,6 @@ "kanban": { "title": "Kanban", "limit": "Limite : {limit}", - "noLimit": "Non défini", "doneBucket": "Colonne des tâches terminées", "doneBucketHint": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée.", "doneBucketHintExtended": "Toute tâche déplacée dans cette colonne sera automatiquement marquée comme terminée. Toute tâche marquée comme terminée ailleurs sera également déplacée.", diff --git a/frontend/src/i18n/lang/he-IL.json b/frontend/src/i18n/lang/he-IL.json index c8abc236a..0477ecffa 100644 --- a/frontend/src/i18n/lang/he-IL.json +++ b/frontend/src/i18n/lang/he-IL.json @@ -318,8 +318,7 @@ "default": "ברירת מחדל", "month": "חודש", "day": "יום", - "hour": "שעה", - "range": "טווח תאריכים" + "hour": "שעה" }, "table": { "title": "טבלה", @@ -328,7 +327,6 @@ "kanban": { "title": "קאנבאן", "limit": "הגבלה: {limit}", - "noLimit": "לא נקבע", "doneBucket": "דלי גמורים", "doneBucketHint": "דלי גמורים נשמר בהצלחה.", "doneBucketHintExtended": "כל המטלות המוכנסות לדלי הגמורים יסומנו אוטומטית כגמורים. כל המטלות המסומנות כגמורים מבחוץ יוזזו גם.", diff --git a/frontend/src/i18n/lang/hr-HR.json b/frontend/src/i18n/lang/hr-HR.json index 3f5f32bf6..77797bbe8 100644 --- a/frontend/src/i18n/lang/hr-HR.json +++ b/frontend/src/i18n/lang/hr-HR.json @@ -289,16 +289,14 @@ "default": "Zadano", "month": "Mjesec", "day": "Dan", - "hour": "Sat", - "range": "Raspon datuma" + "hour": "Sat" }, "table": { "title": "Tablica", "columns": "Stupci" }, "kanban": { - "title": "Kanban", - "noLimit": "Nije postavljeno" + "title": "Kanban" }, "pseudo": { "favorites": { diff --git a/frontend/src/i18n/lang/hu-HU.json b/frontend/src/i18n/lang/hu-HU.json index 178c0bf4a..1cace9336 100644 --- a/frontend/src/i18n/lang/hu-HU.json +++ b/frontend/src/i18n/lang/hu-HU.json @@ -290,8 +290,7 @@ "default": "Alapértelmezett", "month": "Hónap", "day": "Nap", - "hour": "Óra", - "range": "Időintervallum" + "hour": "Óra" }, "table": { "title": "Táblázat", @@ -300,7 +299,6 @@ "kanban": { "title": "Kanban", "limit": "Korlát: {limit}", - "noLimit": "Nincs beállítva", "doneBucket": "Kész vödör", "doneBucketHint": "Az ebbe a csoportba helyezett összes feladat automatikusan készként lesz megjelölve.", "doneBucketHintExtended": "A kész csoportba áthelyezett összes feladat automatikusan készként lesz megjelölve. A máshonnan elvégzettként megjelölt összes feladat is átkerül.", diff --git a/frontend/src/i18n/lang/it-IT.json b/frontend/src/i18n/lang/it-IT.json index ec402b64b..985be6830 100644 --- a/frontend/src/i18n/lang/it-IT.json +++ b/frontend/src/i18n/lang/it-IT.json @@ -362,7 +362,6 @@ "month": "Mese", "day": "Giorno", "hour": "Ora", - "range": "Intervallo di date", "chartLabel": "Progetto diagramma di Gantt", "taskBarsForRow": "Barre delle attività per riga {rowId}", "taskBarLabel": "Attività: {task}. Da {startDate} a {endDate}. {dateType}. Clicca per modificare, trascina per spostare.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Non Impostato", "doneBucket": "Colonna attività completate", "doneBucketHint": "Tutte le attività spostate in questa colonna verranno automaticamente contrassegnate come completate.", "doneBucketHintExtended": "Tutte le attività spostate nella colonna attività completate saranno contrassegnate automaticamente come completate. Anche tutte le attività contrassegnate come completate altrove verranno spostate.", diff --git a/frontend/src/i18n/lang/ja-JP.json b/frontend/src/i18n/lang/ja-JP.json index 5e0d38cbd..2462d9f4e 100644 --- a/frontend/src/i18n/lang/ja-JP.json +++ b/frontend/src/i18n/lang/ja-JP.json @@ -470,7 +470,6 @@ "month": "月", "day": "日", "hour": "時間", - "range": "期間", "chartLabel": "プロジェクトガントチャート", "taskBarsForRow": "行 {rowId} のタスクバー", "taskBarLabel": "タスク: {task}。{startDate} から {endDate} まで。{dateType}。クリックして編集、ドラッグして移動。", @@ -499,7 +498,6 @@ "kanban": { "title": "カンバン", "limit": "上限: {limit}", - "noLimit": "未設定", "doneBucket": "バケットを完了", "doneBucketHint": "このバケットに移動されたすべてのタスクは自動的に完了としてマークされます。", "doneBucketHintExtended": "完了バケットに移動されたすべてのタスクは自動的に完了としてマークされます。他の場所にあるタスクも完了としてマークされるとこのバケットに移動されます。", diff --git a/frontend/src/i18n/lang/ko-KR.json b/frontend/src/i18n/lang/ko-KR.json index f6136e700..3f541c33c 100644 --- a/frontend/src/i18n/lang/ko-KR.json +++ b/frontend/src/i18n/lang/ko-KR.json @@ -323,8 +323,7 @@ "default": "기본값", "month": "월", "day": "일", - "hour": "시", - "range": "날짜 범위" + "hour": "시" }, "table": { "title": "테이블", @@ -333,7 +332,6 @@ "kanban": { "title": "칸반", "limit": "제한: {limit}", - "noLimit": "설정 안함", "doneBucket": "완료 버킷", "doneBucketHint": "이 버킷으로 이동한 모든 할 일은 자동으로 완료로 표시됩니다.", "doneBucketHintExtended": "완료 버킷으로 이동된 모든 할 일은 자동으로 완료로 표시됩니다. 다른 곳에서 완료로 표시된 모든 할 일도 함께 이동됩니다.", diff --git a/frontend/src/i18n/lang/lt-LT.json b/frontend/src/i18n/lang/lt-LT.json index 98bd7dbbe..a27b89c9c 100644 --- a/frontend/src/i18n/lang/lt-LT.json +++ b/frontend/src/i18n/lang/lt-LT.json @@ -320,8 +320,7 @@ "default": "Numatytasis", "month": "Mėnuo", "day": "Diena", - "hour": "Valanda", - "range": "Datos intervalas" + "hour": "Valanda" }, "table": { "title": "Lentelė", @@ -330,7 +329,6 @@ "kanban": { "title": "Kanbanas", "limit": "Limitas: {limit}", - "noLimit": "Nenustatytas", "doneBucket": "Atliktųjų telkinys", "doneBucketHint": "Visos užduotys nukreiptos į šį telkinį bus automatiškai pažymėtos kaip atliktos.", "doneBucketHintExtended": "Visos užduotys perkeltos į atliktą telkiny bus automatiškai pažymėtos kaip atliktos. Visos užduotys pažymėtos kaip atliktos iš kitur bus perkeltos taip pat.", diff --git a/frontend/src/i18n/lang/nl-NL.json b/frontend/src/i18n/lang/nl-NL.json index 8b2d349db..a645a5d6d 100644 --- a/frontend/src/i18n/lang/nl-NL.json +++ b/frontend/src/i18n/lang/nl-NL.json @@ -470,7 +470,6 @@ "month": "Maand", "day": "Dag", "hour": "Uur", - "range": "Datumbereik", "chartLabel": "Project Gantt-diagram", "taskBarsForRow": "Taakbalken voor rij {rowId}", "taskBarLabel": "Taak: {task}. Van {startDate} tot {endDate}. {dateType}. Klik om te bewerken, sleep om te verplaatsen.", @@ -499,7 +498,6 @@ "kanban": { "title": "Kanban", "limit": "Limiet: {limit}", - "noLimit": "Niet ingesteld", "doneBucket": "Categorie 'voltooid'", "doneBucketHint": "Alle taken die je naar deze categorie verplaatst worden automatisch als 'voltooid' gemarkeerd.", "doneBucketHintExtended": "Taken die je verplaatst naar de categorie 'voltooid' worden automatisch gemarkeerd als voltooid. Ook taken die je elders als voltooid aanmerkt, worden hierheen verplaatst.", diff --git a/frontend/src/i18n/lang/no-NO.json b/frontend/src/i18n/lang/no-NO.json index a32189bd4..6761a82dd 100644 --- a/frontend/src/i18n/lang/no-NO.json +++ b/frontend/src/i18n/lang/no-NO.json @@ -353,7 +353,6 @@ "month": "Måned", "day": "Dag", "hour": "Time", - "range": "Datointervall", "chartLabel": "Gantt-kart for prosjekt", "taskBarsForRow": "Oppgavelinjer for rad {rowId}", "taskBarLabel": "Oppgave: {task}. Fra {startDate} til {endDate}. {dateType}. Klikk for å redigere, dra for å flytte.", @@ -377,7 +376,6 @@ "kanban": { "title": "Kanban", "limit": "Begrens: {limit}", - "noLimit": "Ikke angitt", "doneBucket": "Ferdigkurv", "doneBucketHint": "Alle oppgaver som flyttes til denne kurven vil automatisk bli markert som ferdige.", "doneBucketHintExtended": "Alle oppgaver som er flyttet til ferdigkurven, vil automatisk bli markert som ferdige. Alle oppgaver markert som ferdige fra andre steder vil også bli flyttet.", diff --git a/frontend/src/i18n/lang/pl-PL.json b/frontend/src/i18n/lang/pl-PL.json index 796e5e37e..1e3590223 100644 --- a/frontend/src/i18n/lang/pl-PL.json +++ b/frontend/src/i18n/lang/pl-PL.json @@ -300,8 +300,7 @@ "default": "Domyślnie", "month": "Miesiąc", "day": "Dzień", - "hour": "Godzina", - "range": "Zakres dat" + "hour": "Godzina" }, "table": { "title": "Tabela", @@ -310,7 +309,6 @@ "kanban": { "title": "Kanban", "limit": "Limit: {limit}", - "noLimit": "Nie ustawiony", "doneBucket": "Zakończone zadania", "doneBucketHint": "Wszystkie zadania przeniesione do tej kolumny zostaną automatycznie oznaczone jako zakończone.", "doneBucketHintExtended": "Wszystkie zadania przeniesione do kolumny zakończonych zostaną automatycznie oznaczone jako zakończone. Wszystkie zadania oznaczone jako zakończone z innych miejsc zostaną przeniesione.", diff --git a/frontend/src/i18n/lang/pt-BR.json b/frontend/src/i18n/lang/pt-BR.json index e5c91a043..9a2a0f815 100644 --- a/frontend/src/i18n/lang/pt-BR.json +++ b/frontend/src/i18n/lang/pt-BR.json @@ -286,8 +286,7 @@ "default": "Padrão", "month": "Mês", "day": "Dia", - "hour": "Hora", - "range": "Período" + "hour": "Hora" }, "table": { "title": "Tabela", @@ -296,7 +295,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Não definido", "doneBucket": "Bucket concluído", "doneBucketHint": "Todas as tarefas movidas para este bucket serão marcadas automaticamente como concluídas.", "doneBucketHintExtended": "Todas as tarefas movidas para o bucket concluído serão automaticamente concluídas também. Todas as tarefas concluídas de outro lugar serão movidas também.", diff --git a/frontend/src/i18n/lang/pt-PT.json b/frontend/src/i18n/lang/pt-PT.json index 58c6e6aca..e986fe4ca 100644 --- a/frontend/src/i18n/lang/pt-PT.json +++ b/frontend/src/i18n/lang/pt-PT.json @@ -362,7 +362,6 @@ "month": "Mês", "day": "Dia", "hour": "Hora", - "range": "Intervalo de Datas", "chartLabel": "Gráfico de Gantt do projeto", "taskBarsForRow": "Barras de tarefas para a linha {rowId}", "taskBarLabel": "Tarefa: {task}. De {startDate} a {endDate}. {dateType}. Clica para editar, arrasta para mover.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Limite: {limit}", - "noLimit": "Não Definido", "doneBucket": "Conjunto concluído", "doneBucketHint": "Todas as tarefas movidas para este conjunto serão automaticamente marcadas como concluídas.", "doneBucketHintExtended": "Todas as tarefas movidas para o conjunto concluído serão marcadas automaticamente como concluídas. Todas as tarefas marcadas como concluídas em outro lugar também serão movidas.", diff --git a/frontend/src/i18n/lang/ru-RU.json b/frontend/src/i18n/lang/ru-RU.json index 9f4163bbf..8559e34e8 100644 --- a/frontend/src/i18n/lang/ru-RU.json +++ b/frontend/src/i18n/lang/ru-RU.json @@ -407,7 +407,6 @@ "month": "Месяц", "day": "День", "hour": "Час", - "range": "Диапазон", "chartLabel": "Диаграмма Ганта", "taskBarsForRow": "Задачи в строке {rowId}", "taskBarLabel": "Задача: {task}. С {startDate} по {endDate}. {dateType}. Нажмите для изменения, потяните для перемещения.", @@ -435,7 +434,6 @@ "kanban": { "title": "Канбан", "limit": "Лимит: {limit}", - "noLimit": "не установлен", "doneBucket": "Колонка завершённых", "doneBucketHint": "Все задачи, помещённые в эту колонку, автоматически отмечаются как завершённые.", "doneBucketHintExtended": "Все задачи, перенесённые в колонку завершённых, будут помечены как завершённые. Все задачи, помеченные как завершённые, также будут перемещены в эту колонку.", diff --git a/frontend/src/i18n/lang/sl-SI.json b/frontend/src/i18n/lang/sl-SI.json index faaf474af..d00e67842 100644 --- a/frontend/src/i18n/lang/sl-SI.json +++ b/frontend/src/i18n/lang/sl-SI.json @@ -314,8 +314,7 @@ "default": "Privzeto", "month": "Mesec", "day": "Dan", - "hour": "Ura", - "range": "Datumski obseg" + "hour": "Ura" }, "table": { "title": "Tabela", @@ -324,7 +323,6 @@ "kanban": { "title": "Kanban", "limit": "Omejitev: {limit}", - "noLimit": "Ni nastavljeno", "doneBucket": "Vedro končanih nalog", "doneBucketHint": "Vse naloge, premaknjene v to vedro, bodo samodejno označene kot opravljene.", "doneBucketHintExtended": "Vse naloge, premaknjene v vedro končanih nalog, bodo samodejno označene kot opravljene. Premaknjene bodo tudi vse naloge, ki so od drugje označena kot opravljene.", diff --git a/frontend/src/i18n/lang/sv-SE.json b/frontend/src/i18n/lang/sv-SE.json index 0bbe11cd7..8d7f7e72d 100644 --- a/frontend/src/i18n/lang/sv-SE.json +++ b/frontend/src/i18n/lang/sv-SE.json @@ -362,7 +362,6 @@ "month": "Månad", "day": "Dag", "hour": "Timme", - "range": "Datumintervall", "chartLabel": "Projektets Gantt-schema", "taskBarsForRow": "Uppgiftsstaplar för rad {rowId}", "taskBarLabel": "Uppgift: {task}. Från {startDate} till {endDate}. {dateType}. Klicka för att redigera, dra för att flytta.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Gräns: {limit}", - "noLimit": "Ej inställt", "doneBucket": "Färdigkolumn", "doneBucketHint": "Alla uppgifter som flyttas till den här kolumnen markeras automatiskt som klara.", "doneBucketHintExtended": "Alla uppgifter som flyttas till färdigkolumnen markeras som färdiga automatiskt. Alla uppgifter som markerats som färdiga från andra håll kommer också att flyttas.", diff --git a/frontend/src/i18n/lang/tr-TR.json b/frontend/src/i18n/lang/tr-TR.json index 40a33632b..bcd0bd6f6 100644 --- a/frontend/src/i18n/lang/tr-TR.json +++ b/frontend/src/i18n/lang/tr-TR.json @@ -362,7 +362,6 @@ "month": "Ay", "day": "Gün", "hour": "Saat", - "range": "Tarih Aralığı", "chartLabel": "Proje Gantt Şeması", "taskBarsForRow": "{rowId} satırı için görev çubukları", "taskBarLabel": "Görev: {task}. {startDate} tarihinden {endDate} tarihine. {dateType}. Düzenlemek için tıklayın, taşımak için sürükleyin.", @@ -386,7 +385,6 @@ "kanban": { "title": "Kanban", "limit": "Sınır: {limit}", - "noLimit": "Belirlenmedi", "doneBucket": "Tamamlananlar kutusu", "doneBucketHint": "Bu kutuya taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir.", "doneBucketHintExtended": "Tamamlananlar kutusuna taşınan tüm görevler otomatik olarak tamamlandı olarak işaretlenir. Başka bir yerden tamamlandı olarak işaretlenen görevler de buraya taşınır.", diff --git a/frontend/src/i18n/lang/uk-UA.json b/frontend/src/i18n/lang/uk-UA.json index 18b569459..aff96ecfa 100644 --- a/frontend/src/i18n/lang/uk-UA.json +++ b/frontend/src/i18n/lang/uk-UA.json @@ -470,7 +470,6 @@ "month": "Місяць", "day": "День", "hour": "Година", - "range": "Проміжок днів", "chartLabel": "Діаграма Ганта", "taskBarsForRow": "Смуги завдань для рядка {rowId}", "taskBarLabel": "Завдання: {task}. З {startDate} по {endDate}. {dateType}. Натисніть, щоб редагувати, перетягніть, щоб перемістити.", @@ -499,7 +498,6 @@ "kanban": { "title": "Дошка", "limit": "Межа: {limit}", - "noLimit": "Немає", "doneBucket": "Колонка «Виконано»", "doneBucketHint": "Всі завдання, переміщені в цю колонку, будуть автоматично позначені як виконані.", "doneBucketHintExtended": "Всі завдання, що потрапляють у колонку «Виконано», автоматично відмічаються як виконані. Завдання, позначені як виконані в інших місцях, також перемістяться сюди.", diff --git a/frontend/src/i18n/lang/vi-VN.json b/frontend/src/i18n/lang/vi-VN.json index ad559767d..9f845a319 100644 --- a/frontend/src/i18n/lang/vi-VN.json +++ b/frontend/src/i18n/lang/vi-VN.json @@ -319,8 +319,7 @@ "default": "Mặc định", "month": "Tháng", "day": "Ngày", - "hour": "Giờ", - "range": "Khoảng thời gian" + "hour": "Giờ" }, "table": { "title": "Bảng", @@ -329,7 +328,6 @@ "kanban": { "title": "Kanban", "limit": "Giới hạn: {limit}", - "noLimit": "Không giới hạn", "doneBucket": "Cột hoàn thành", "doneBucketHint": "Tất cả các tác vụ được chuyển đến cột này sẽ được tự động đánh dấu là hoàn thành.", "doneBucketHintExtended": "Tất cả các tác vụ được đưa đến cột hoàn thành sẽ được tự động đánh dấu là hoàn thành. Tất cả các tác vụ hoàn thành từ các phần khác cũng sẽ được chuyển tới đây.", diff --git a/frontend/src/i18n/lang/zh-CN.json b/frontend/src/i18n/lang/zh-CN.json index 329eb42ff..8e7ce7888 100644 --- a/frontend/src/i18n/lang/zh-CN.json +++ b/frontend/src/i18n/lang/zh-CN.json @@ -338,7 +338,6 @@ "month": "月", "day": "日", "hour": "时", - "range": "日期范围", "chartLabel": "项目甘特图", "scheduledDates": "预定日期", "estimatedDates": "估计日期" @@ -350,7 +349,6 @@ "kanban": { "title": "看板", "limit": "限制: {limit}", - "noLimit": "未设置", "doneBucket": "已完成的桶数", "doneBucketHint": "移入此存储桶的所有任务将自动标记为已完成。", "doneBucketHintExtended": "所有移入已完成存储桶的任务都将自动标记为已完成。 从其他位置标记为已完成的任务也将被移动。", diff --git a/frontend/src/i18n/lang/zh-TW.json b/frontend/src/i18n/lang/zh-TW.json index b55b8b4fb..594fb17c5 100644 --- a/frontend/src/i18n/lang/zh-TW.json +++ b/frontend/src/i18n/lang/zh-TW.json @@ -362,7 +362,6 @@ "month": "月", "day": "日", "hour": "時", - "range": "日期範圍", "chartLabel": "專案甘特圖", "taskBarsForRow": "第 {rowId} 列的任務列", "taskBarLabel": "任務:{task}。從 {startDate} 到 {endDate}。{dateType}。點擊編輯,拖曳移動。", @@ -386,7 +385,6 @@ "kanban": { "title": "看板", "limit": "限制: {limit}", - "noLimit": "未設定", "doneBucket": "已完成類別", "doneBucketHint": "移入此類別的任務將自動標記為已完成。", "doneBucketHintExtended": "所有移入已完成類別的任務將自動標記為已完成。 其他位置標記為已完成的任務也將被移動。", From e1512b6b538c19a967b7590538cb3b7b634294b8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 10:54:11 +0200 Subject: [PATCH 02/45] chore(deps): update devenv --- devenv.lock | 64 ++--------------------------------------------------- 1 file changed, 2 insertions(+), 62 deletions(-) diff --git a/devenv.lock b/devenv.lock index 6184f1dc8..6ed2e5f71 100644 --- a/devenv.lock +++ b/devenv.lock @@ -16,62 +16,6 @@ "type": "github" } }, - "flake-compat": { - "flake": false, - "locked": { - "lastModified": 1767039857, - "owner": "NixOS", - "repo": "flake-compat", - "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", - "type": "github" - }, - "original": { - "owner": "NixOS", - "repo": "flake-compat", - "type": "github" - } - }, - "git-hooks": { - "inputs": { - "flake-compat": "flake-compat", - "gitignore": "gitignore", - "nixpkgs": [ - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1772893680, - "owner": "cachix", - "repo": "git-hooks.nix", - "rev": "8baab586afc9c9b57645a734c820e4ac0a604af9", - "type": "github" - }, - "original": { - "owner": "cachix", - "repo": "git-hooks.nix", - "type": "github" - } - }, - "gitignore": { - "inputs": { - "nixpkgs": [ - "git-hooks", - "nixpkgs" - ] - }, - "locked": { - "lastModified": 1762808025, - "owner": "hercules-ci", - "repo": "gitignore.nix", - "rev": "cb5e3fdca1de58ccbc3ef53de65bd372b48f567c", - "type": "github" - }, - "original": { - "owner": "hercules-ci", - "repo": "gitignore.nix", - "type": "github" - } - }, "nixpkgs": { "inputs": { "nixpkgs-src": "nixpkgs-src" @@ -125,15 +69,11 @@ "root": { "inputs": { "devenv": "devenv", - "git-hooks": "git-hooks", "nixpkgs": "nixpkgs", - "nixpkgs-unstable": "nixpkgs-unstable", - "pre-commit-hooks": [ - "git-hooks" - ] + "nixpkgs-unstable": "nixpkgs-unstable" } } }, "root": "root", "version": 7 -} +} \ No newline at end of file From c9c2c58c1624df83328995ed55aa1c9a1a10925f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 11:06:14 +0000 Subject: [PATCH 03/45] feat(labels): let bot owners manage labels created by their bots Bot owners inherit read/update/delete permission on labels created by bots they own, mirroring the bot-owner branch already used by API tokens (see api_tokens_permissions.go). Without this, a label a bot creates is permanently locked to that bot and the human owner cannot maintain it. https://claude.ai/code/session_016x6mUPJuuQEeXpHY814iLh --- pkg/db/fixtures/labels.yml | 8 ++++ pkg/models/label_permissions.go | 22 ++++++++- pkg/models/label_task.go | 7 ++- pkg/models/label_test.go | 80 +++++++++++++++++++++++++++++++++ pkg/webtests/huma_label_test.go | 66 +++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 3 deletions(-) diff --git a/pkg/db/fixtures/labels.yml b/pkg/db/fixtures/labels.yml index d685d6392..c84663d34 100644 --- a/pkg/db/fixtures/labels.yml +++ b/pkg/db/fixtures/labels.yml @@ -43,3 +43,11 @@ created_by_id: 1 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +# Covers the bot-owner branch: created by bot 23, whose owner is user 21. +# User 21 should be able to read/update/delete it; user 22 (who owns bot 24) +# should not. +- id: 9 + title: 'Label #9 - created by bot 23 owned by user 21' + created_by_id: 23 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/models/label_permissions.go b/pkg/models/label_permissions.go index 75468d1bb..2854cbf7b 100644 --- a/pkg/models/label_permissions.go +++ b/pkg/models/label_permissions.go @@ -17,6 +17,7 @@ package models import ( + "code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/web" "xorm.io/builder" "xorm.io/xorm" @@ -57,7 +58,19 @@ func (l *Label) isLabelOwner(s *xorm.Session, a web.Auth) (bool, error) { if err != nil { return false, err } - return lorig.CreatedByID == a.GetID(), nil + if lorig.CreatedByID == a.GetID() { + return true, nil + } + + // A bot owner inherits write/delete access to labels their bots created. + creator, err := user.GetUserByID(s, lorig.CreatedByID) + if err != nil { + if user.IsErrUserDoesNotExist(err) { + return false, nil + } + return false, err + } + return creator.IsBot() && creator.BotOwnerID == a.GetID(), nil } // hasAccessToLabel reports whether the caller can read a label and, if so, @@ -91,7 +104,12 @@ func (l *Label) hasAccessToLabel(s *xorm.Session, a web.Auth) (has bool, maxPerm accessBranches := []builder.Cond{labelAttachedToAccessibleTask} if !isLinkShare { - accessBranches = append(accessBranches, builder.Eq{"labels.created_by_id": a.GetID()}) + accessBranches = append(accessBranches, + builder.Eq{"labels.created_by_id": a.GetID()}, + builder.In("labels.created_by_id", + builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": a.GetID()}), + ), + ) } cond := builder.And( diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index ad8b46c2c..312f5d027 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -212,7 +212,12 @@ func GetLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*Lab ), cond) } if opts.GetUnusedLabels && !isLinkShareAuth { - cond = builder.Or(cond, builder.Eq{"labels.created_by_id": opts.User.GetID()}) + cond = builder.Or(cond, + builder.Eq{"labels.created_by_id": opts.User.GetID()}, + builder.In("labels.created_by_id", + builder.Select("id").From("users").Where(builder.Eq{"bot_owner_id": opts.User.GetID()}), + ), + ) } ids := []int64{} diff --git a/pkg/models/label_test.go b/pkg/models/label_test.go index 5320678b3..02bff9f06 100644 --- a/pkg/models/label_test.go +++ b/pkg/models/label_test.go @@ -336,6 +336,46 @@ func TestLabel_ReadOne(t *testing.T) { wantForbidden: true, auth: &user.User{ID: 1}, }, + { + // Label 9 was created by bot 23, whose owner is user 21. The + // bot owner inherits admin-level access. + name: "bot owner can read label created by their bot", + fields: fields{ + ID: 9, + }, + want: &Label{ + ID: 9, + Title: "Label #9 - created by bot 23 owned by user 21", + CreatedByID: 23, + CreatedBy: &user.User{ + ID: 23, + Name: "Owner A Assistant", + Username: "bot-owner-a-assistant", + Issuer: "local", + BotOwnerID: 21, + EmailRemindersEnabled: true, + OverdueTasksRemindersEnabled: true, + OverdueTasksRemindersTime: "09:00", + Created: testCreatedTime, + Updated: testUpdatedTime, + }, + Created: testCreatedTime, + Updated: testUpdatedTime, + }, + auth: &user.User{ID: 21}, + assertMaxPermission: true, + wantMaxPermission: int(PermissionAdmin), + }, + { + // User 22 owns a different bot and must not see another owner's + // bot's label. + name: "non-owner cannot read label created by someone else's bot", + fields: fields{ + ID: 9, + }, + wantForbidden: true, + auth: &user.User{ID: 22}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -507,6 +547,27 @@ func TestLabel_Update(t *testing.T) { auth: &user.User{ID: 1}, wantForbidden: true, }, + { + // Label 9 was created by bot 23 (owned by user 21). The bot's + // owner inherits update permission. + name: "bot owner can update label created by their bot", + fields: fields{ + ID: 9, + Title: "new and better", + }, + auth: &user.User{ID: 21}, + }, + { + // User 22 owns a different bot and must not be able to update + // another owner's bot's label. + name: "non-owner cannot update label created by someone else's bot", + fields: fields{ + ID: 9, + Title: "new and better", + }, + auth: &user.User{ID: 22}, + wantForbidden: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -594,6 +655,25 @@ func TestLabel_Delete(t *testing.T) { auth: &user.User{ID: 1}, wantForbidden: true, }, + { + // Label 9 was created by bot 23 (owned by user 21). The bot's + // owner inherits delete permission. + name: "bot owner can delete label created by their bot", + fields: fields{ + ID: 9, + }, + auth: &user.User{ID: 21}, + }, + { + // User 22 owns a different bot and must not be able to delete + // another owner's bot's label. + name: "non-owner cannot delete label created by someone else's bot", + fields: fields{ + ID: 9, + }, + auth: &user.User{ID: 22}, + wantForbidden: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/webtests/huma_label_test.go b/pkg/webtests/huma_label_test.go index 02b314dc5..eb59ea675 100644 --- a/pkg/webtests/huma_label_test.go +++ b/pkg/webtests/huma_label_test.go @@ -24,10 +24,17 @@ import ( "strings" "testing" + "code.vikunja.io/api/pkg/user" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// testuser22 is the second bot owner from pkg/db/fixtures/users.yml; user22 +// owns bot 24. Paired with testuser21 to assert bot-owner isolation: each +// owner sees and acts on their own bots' resources, never the other's. +var testuser22 = user.User{ID: 22, Username: "user_bot_owner_b", Issuer: "local"} + // TestHumaLabel mirrors v1's TestProject shape so v2 contract parity is // readable side-by-side. Labels has no v1 webtest; coverage is ported 1:1 // from the model-level matrix in pkg/models/label_test.go so the v2 HTTP @@ -228,6 +235,65 @@ func TestHumaLabel(t *testing.T) { }) } +// TestHumaLabel_BotOwner asserts that bot owners can read, update, and delete +// labels that were created by bots they own. Fixture label #9 is owned by +// bot 23, whose owner is user 21 (testuser21); user 22 owns a different bot +// and must not see or touch it. +func TestHumaLabel_BotOwner(t *testing.T) { + botOwner := webHandlerTestV2{ + user: &testuser21, + basePath: "/api/v2/labels", + idParam: "label", + t: t, + } + require.NoError(t, botOwner.ensureEnv()) + otherOwner := webHandlerTestV2{ + user: &testuser22, + basePath: "/api/v2/labels", + idParam: "label", + t: t, + e: botOwner.e, + } + + t.Run("ReadOne - bot owner can read label created by their bot", func(t *testing.T) { + rec, err := botOwner.testReadOneWithUser(nil, map[string]string{"label": "9"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"Label #9 - created by bot 23 owned by user 21"`) + }) + t.Run("ReadOne - non-owner cannot read another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testReadOneWithUser(nil, map[string]string{"label": "9"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("ReadAll - bot owner's listing surfaces their bot's labels", func(t *testing.T) { + rec, err := botOwner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + ids := labelIDsFromReadAll(t, rec.Body.Bytes()) + assert.Contains(t, ids, int64(9), "label #9 (created by user 21's bot) must be listed") + }) + t.Run("Update - bot owner can update label created by their bot", func(t *testing.T) { + rec, err := botOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"renamed by owner"}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"renamed by owner"`) + }) + t.Run("Update - non-owner cannot update another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testUpdateWithUser(nil, map[string]string{"label": "9"}, `{"title":"hijack"}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Delete - non-owner cannot delete another owner's bot's label", func(t *testing.T) { + _, err := otherOwner.testDeleteWithUser(nil, map[string]string{"label": "9"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Delete - bot owner can delete label created by their bot", func(t *testing.T) { + // Run last so the earlier subtests still have label #9 to operate on. + rec, err := botOwner.testDeleteWithUser(nil, map[string]string{"label": "9"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + }) +} + // labelIDsFromReadAll extracts the label IDs from a v2 paginated list body so // the visible set can be asserted exactly rather than via substring matching. func labelIDsFromReadAll(t *testing.T, body []byte) []int64 { From cb0d24dae1f08e61f0af86a708e9f3d8a876bccc Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:07:02 +0000 Subject: [PATCH 04/45] chore(deps): update dev-dependencies to v8.61.0 --- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 174 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index d5204b933..2344362ae 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -117,8 +117,8 @@ "@types/node": "24.13.1", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.60.1", - "@typescript-eslint/parser": "8.60.1", + "@typescript-eslint/eslint-plugin": "8.61.0", + "@typescript-eslint/parser": "8.61.0", "@vitejs/plugin-vue": "6.0.7", "@vue/eslint-config-typescript": "14.8.0", "@vue/test-utils": "2.4.11", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 624ffc07d..e790717ee 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -212,17 +212,17 @@ importers: specifier: 8.18.1 version: 8.18.1 '@typescript-eslint/eslint-plugin': - specifier: 8.60.1 - version: 8.60.1(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.61.0 + version: 8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.60.1 - version: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.61.0 + version: 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.7 version: 6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.8.0 - version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.11 version: 2.4.11(@vue/compiler-dom@3.5.27)(@vue/server-renderer@3.5.27(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) @@ -255,7 +255,7 @@ importers: version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.9.2 - version: 10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + version: 10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: specifier: 20.10.2 version: 20.10.2 @@ -2931,6 +2931,14 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.60.1': resolution: {integrity: sha512-A0M6ua6H252bVjPvvtSgl2QA4+ET9S5Mtkb2GDyTxIhH/C4qDItT7RQNO5PhMC6NXGYXOR9dIalcDDgBKT7oFA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2938,6 +2946,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.58.0': resolution: {integrity: sha512-8Q/wBPWLQP1j16NxoPNIKpDZFMaxl7yWIoqXWYeWO+Bbd2mjgvoF0dxP2jKZg5+x49rgKdf7Ck473M8PC3V9lg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2950,6 +2965,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.58.0': resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2958,20 +2979,24 @@ packages: resolution: {integrity: sha512-gvI5OQoptnxQnchOirukCuQ55svJSTuD/4k5+pC267xyBtYry748R9/c3tYUzb/iE6RZfllRz2lVulLCHkTm4w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.58.0': resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.0': - resolution: {integrity: sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==} + '@typescript-eslint/tsconfig-utils@8.60.1': + resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' - '@typescript-eslint/tsconfig-utils@8.60.1': - resolution: {integrity: sha512-nh8w4qAteiKuZu3pSSzG/yGKpw0OlkrKnzFmbVRenKaD4qc+7i1GrmZaLVkr8rk4uipiPGMOW4YsM6WmKZ5CvA==} + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' @@ -2983,6 +3008,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/types@8.58.0': resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2991,6 +3023,10 @@ packages: resolution: {integrity: sha512-4h0tY8ppCkdCzcrl2YM5M3my0xsE1Tf8om3owEu5oPWmXwkKRmk0j0LGDzYBGUcAlesEbxBhazqu/K4cu3Ug7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.0': resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3003,6 +3039,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.60.1': resolution: {integrity: sha512-h2MPBLoNtjc3qZWfY3Tl51yPorQ2McHn8pJfcMNTcIvrrZrr90Ykffit0yjrPFWQcRcUxzH20+6OcVdW4yHtUg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3010,6 +3052,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.58.0': resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3018,6 +3067,10 @@ packages: resolution: {integrity: sha512-EbGRQg4FhrmwLodl+t3JNAnXHWVr9Vp+Zl1QBZVPY4ByfkzIT8cX3K6QWODHtkIZqqJVEWvhHSx3v5PDHsaQag==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -9692,6 +9745,22 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.60.1 @@ -9704,9 +9773,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/project-service@8.58.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.60.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 debug: 4.4.3 typescript: 5.9.3 @@ -9722,6 +9803,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.61.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 @@ -9732,15 +9822,20 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/tsconfig-utils@8.60.1(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -9756,10 +9851,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/type-utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/types@8.58.0': {} '@typescript-eslint/types@8.60.1': {} + '@typescript-eslint/types@8.61.0': {} + '@typescript-eslint/typescript-estree@8.58.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.58.0(typescript@5.9.3) @@ -9790,6 +9899,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.61.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@5.9.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9801,6 +9925,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 @@ -9811,6 +9946,11 @@ snapshots: '@typescript-eslint/types': 8.60.1 eslint-visitor-keys: 5.0.0 + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.0 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-vue@6.0.7(vite@7.3.5(@types/node@24.13.1)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.100.0)(sass@1.100.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': @@ -9967,11 +10107,11 @@ snapshots: '@vue/devtools-shared@8.1.2': {} - '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.8.0(eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-vue: 10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) @@ -10948,7 +11088,7 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.9.2(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -10959,7 +11099,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.60.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.61.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: From 732cd115a516a5ea1ddff447becd9252b35c56a1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:08:06 +0200 Subject: [PATCH 05/45] docs(api/v2): tag bulk assignee fields for the v2 schema --- pkg/models/task_assignees.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/task_assignees.go b/pkg/models/task_assignees.go index 6324c5223..517d9fc5d 100644 --- a/pkg/models/task_assignees.go +++ b/pkg/models/task_assignees.go @@ -346,7 +346,7 @@ func (la *TaskAssginee) ReadAll(s *xorm.Session, a web.Auth, search string, page // BulkAssignees is a helper struct used to update multiple assignees at once. type BulkAssignees struct { // A project with all assignees - Assignees []*user.User `json:"assignees"` + Assignees []*user.User `json:"assignees" doc:"The full set of users to assign to the task. This replaces the task's current assignees: users not in this list are unassigned. Pass an empty array to unassign everyone. Each user must have access to the task's project."` TaskID int64 `json:"-" param:"projecttask"` web.CRUDable `json:"-"` From bf2a65dcafe218ccc0ed72efd6e350120caea3b6 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:08:06 +0200 Subject: [PATCH 06/45] feat(api/v2): add bulk assignee replacement on /api/v2 --- pkg/routes/api/v2/task_assignees_bulk.go | 60 +++++++++ pkg/webtests/huma_task_assignee_bulk_test.go | 125 +++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 pkg/routes/api/v2/task_assignees_bulk.go create mode 100644 pkg/webtests/huma_task_assignee_bulk_test.go diff --git a/pkg/routes/api/v2/task_assignees_bulk.go b/pkg/routes/api/v2/task_assignees_bulk.go new file mode 100644 index 000000000..a60d84248 --- /dev/null +++ b/pkg/routes/api/v2/task_assignees_bulk.go @@ -0,0 +1,60 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskAssigneeBulkRoutes wires the bulk assignee replacement onto the +// Huma API. PUT is the honest verb — the operation replaces the task's whole +// assignee set idempotently — even though the model implements it as a Create. +func RegisterTaskAssigneeBulkRoutes(api huma.API) { + tags := []string{"assignees"} + + Register(api, huma.Operation{ + OperationID: "task-assignees-bulk", + Summary: "Replace all assignees of a task", + Description: "Replaces the task's full assignee set with the users in the body: users not in the list are unassigned, new ones are added. Pass an empty array to unassign everyone. Each assignee must have access to the task's project, and the caller needs write access to the task.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/assignees/bulk", + Tags: tags, + }, taskAssigneesBulk) +} + +func init() { AddRouteRegistrar(RegisterTaskAssigneeBulkRoutes) } + +func taskAssigneesBulk(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask"` + Body models.BulkAssignees +}) (*singleBody[models.BulkAssignees], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + in.Body.TaskID = in.TaskID // URL wins over body + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.BulkAssignees]{Body: &in.Body}, nil +} diff --git a/pkg/webtests/huma_task_assignee_bulk_test.go b/pkg/webtests/huma_task_assignee_bulk_test.go new file mode 100644 index 000000000..0a54de02f --- /dev/null +++ b/pkg/webtests/huma_task_assignee_bulk_test.go @@ -0,0 +1,125 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTaskAssigneeBulk proves the v2 bulk-assignee replace contract: +// PUT /tasks/{projecttask}/assignees/bulk swaps the task's full assignee set +// for the posted list. Like the single-assignee test it gates on write access +// to the task's project (CanCreate → canDoTaskAssingee → project.CanUpdate). +// +// Fixture topology (pkg/db/fixtures/task_assignees.yml, tasks.yml, projects.yml, +// users_projects.yml): +// - task 30 (project 1, owned by user1): assignees user1 (#1) and user2 (#2). +// user2 is a fixture row only; user2 has NO access to project 1, so it can +// be removed but never freshly added — replace cases here only remove it. +// - tasks 16/19 (shared to user1 with write): user1 has project access, so +// it is a valid assignee there — used for the add-from-empty case. +// - tasks 15/18: shared read-only — write is forbidden. +// - task 34 (project 20, user13): user1 has no access at all. +func TestHumaTaskAssigneeBulk(t *testing.T) { + // One Echo env shared across users; setupTestEnv rotates the JWT secret per + // call, so a second env would 401 tokens minted against the first. + base := &webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, base.ensureEnv()) + + bulkPut := func(taskID string, u *user.User, payload string) (ids []int64, err error) { + h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees/bulk", t: t, e: base.e} + rec, err := h.serve(http.MethodPut, h.basePath, payload) + if err != nil { + return nil, err + } + // PUT defaults to 200 from the Register wrapper for a non-create verb. + assert.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + return assigneeIDsFromReadAll(t, rec.Body.Bytes()), nil + } + // readAssignees fetches the current assignee set so a replace is verified + // against persisted state, not just the response echo. + readAssignees := func(taskID string, u *user.User) []int64 { + h := &webHandlerTestV2{user: u, basePath: "/api/v2/tasks/" + taskID + "/assignees", idParam: "user", t: t, e: base.e} + rec, err := h.testReadAllWithUser(nil, nil) + require.NoError(t, err) + return assigneeIDsFromReadAll(t, rec.Body.Bytes()) + } + + t.Run("Replace removes assignees not in the list", func(t *testing.T) { + // task 30 starts as {1,2}; replacing with {1} must drop user2. + require.ElementsMatch(t, []int64{1, 2}, readAssignees("30", &testuser1)) + _, err := bulkPut("30", &testuser1, `{"assignees":[{"id":1}]}`) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1}, readAssignees("30", &testuser1), + "user2 must be unassigned after the replace") + }) + + t.Run("Empty list unassigns everyone", func(t *testing.T) { + // task 30 now holds {1}; an empty array clears it entirely. + _, err := bulkPut("30", &testuser1, `{"assignees":[]}`) + require.NoError(t, err) + assert.Empty(t, readAssignees("30", &testuser1), + "an empty assignees array must remove all assignees") + }) + + t.Run("Replace adds new assignees", func(t *testing.T) { + // task 16 is shared to user1 with write access and starts with no + // assignees; user1 has project access, so it is a valid new assignee. + require.Empty(t, readAssignees("16", &testuser1)) + _, err := bulkPut("16", &testuser1, `{"assignees":[{"id":1}]}`) + require.NoError(t, err) + assert.ElementsMatch(t, []int64{1}, readAssignees("16", &testuser1), + "user1 must be assigned after the replace") + }) + + t.Run("Forbidden - read-only share", func(t *testing.T) { + // task 18 is shared to user1 read-only; bulk replace needs write. + _, err := bulkPut("18", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Forbidden - no access at all", func(t *testing.T) { + // task 34 belongs to user13's private project 20. + _, err := bulkPut("34", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Forbidden - user without project access", func(t *testing.T) { + // user6 has no access to project 1, so it cannot write task 1. + _, err := bulkPut("1", &testuser6, `{"assignees":[{"id":6}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + + t.Run("Nonexisting task", func(t *testing.T) { + // The write check resolves the project from the task, so a missing task + // surfaces project-does-not-exist as a 404. + _, err := bulkPut("99999", &testuser1, `{"assignees":[{"id":1}]}`) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + assertHandlerErrorCode(t, err, models.ErrCodeProjectDoesNotExist) + }) +} From aa144b9a392596d713660236fae619b7a085b0d2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:10:05 +0200 Subject: [PATCH 07/45] feat(api/v2): add task read-status marking on /api/v2 --- pkg/routes/api/v2/task_unread_status.go | 73 ++++++++++++++++ pkg/webtests/huma_task_unread_status_test.go | 88 ++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 pkg/routes/api/v2/task_unread_status.go create mode 100644 pkg/webtests/huma_task_unread_status_test.go diff --git a/pkg/routes/api/v2/task_unread_status.go b/pkg/routes/api/v2/task_unread_status.go new file mode 100644 index 000000000..f7a944092 --- /dev/null +++ b/pkg/routes/api/v2/task_unread_status.go @@ -0,0 +1,73 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// taskReadBody confirms the mark-read action: the underlying model carries no +// JSON-exposed fields, so it returns a status message rather than a resource. +type taskReadBody struct { + Body struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` + } +} + +// RegisterTaskUnreadStatusRoutes wires the mark-task-as-read action onto the Huma API. +// +// Marking a task read clears the caller's unread entry for it, which is what +// drives the per-task "unread" dot shown for mentions and other notifications. +// The model's Update deletes that entry, so the action is idempotent — PUT, not +// POST. It is also unconditional: there is no read entry to clear for a task the +// caller cannot see, so it succeeds as a no-op rather than refusing. +func RegisterTaskUnreadStatusRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-mark-read", + Summary: "Mark a task as read", + Description: "Clears the authenticated user's unread status for a task, dismissing the unread indicator raised by mentions and other task notifications. Idempotent: marking an already-read or inaccessible task succeeds as a no-op.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/read", + Tags: tags, + }, tasksMarkRead) +} + +func init() { AddRouteRegistrar(RegisterTaskUnreadStatusRoutes) } + +func tasksMarkRead(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task to mark as read."` +}) (*taskReadBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + t := &models.TaskUnreadStatus{TaskID: in.TaskID} + if err := handler.DoUpdate(ctx, t, a); err != nil { + return nil, translateDomainError(err) + } + out := &taskReadBody{} + out.Body.Message = "success" + return out, nil +} diff --git a/pkg/webtests/huma_task_unread_status_test.go b/pkg/webtests/huma_task_unread_status_test.go new file mode 100644 index 000000000..9ea27544d --- /dev/null +++ b/pkg/webtests/huma_task_unread_status_test.go @@ -0,0 +1,88 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTaskUnreadStatus ports v1's POST /tasks/:projecttask/read (no v1 +// webtest exists). The action deletes the caller's unread entry for the task; +// there is no fixture file for task_unread_statuses, so the table starts empty +// and the test seeds the row it expects to clear. +// +// Note on the permission model: the v1 handler enforces nothing — CanUpdate is +// a hardcoded true and Update is an unconditional DELETE on (task_id, user_id). +// A task the caller can't see (or doesn't exist) therefore has no row to clear +// and the call succeeds as a no-op. The only thing actually gated is auth, so +// that is what the negative case covers. +func TestHumaTaskUnreadStatus(t *testing.T) { + t.Run("Normal - clears the caller's unread entry", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + s := db.NewSession() + _, err = s.Insert(&models.TaskUnreadStatus{TaskID: 1, UserID: testuser1.ID}) + require.NoError(t, err) + require.NoError(t, s.Commit()) + require.NoError(t, s.Close()) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + + db.AssertMissing(t, "task_unread_statuses", map[string]interface{}{ + "task_id": 1, + "user_id": testuser1.ID, + }) + }) + + t.Run("No-op - already read, no entry to clear", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"message":"success"`) + }) + + t.Run("No-op - nonexistent task (unenforced, mirrors v1)", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/read", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Anonymous request is rejected with 401", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/read", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "anonymous must get 401; body: %s", rec.Body.String()) + }) +} From 9eca20fe439a1f1271dbc3456c270c61c7bf02e7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:15:00 +0200 Subject: [PATCH 08/45] docs(api/v2): tag task bucket fields for the v2 schema --- pkg/models/kanban_task_bucket.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index 0b2b06eaf..108b8a371 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -30,16 +30,16 @@ import ( // A task can only appear once per project view which is ensured by a // unique index on the combination of task_id and project_view_id. type TaskBucket struct { - BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket"` - Bucket *Bucket `xorm:"-" json:"bucket"` + BucketID int64 `xorm:"bigint not null index" json:"bucket_id" param:"bucket" doc:"The bucket to move the task into. On /api/v2 this is taken from the URL; a value in the body is ignored."` + Bucket *Bucket `xorm:"-" json:"bucket" readOnly:"true" doc:"The resolved target bucket, including its updated task count."` // The task which belongs to the bucket. Together with ProjectViewID // this field is part of a unique index to prevent duplicates. - TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id"` + TaskID int64 `xorm:"bigint not null index unique(task_view)" json:"task_id" doc:"The id of the task to place in the bucket."` // The view this bucket belongs to. Combined with TaskID this forms a // unique index. - ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view"` + ProjectViewID int64 `xorm:"bigint not null index unique(task_view)" json:"project_view_id" param:"view" doc:"The view the bucket belongs to. On /api/v2 this is taken from the URL; a value in the body is ignored."` ProjectID int64 `xorm:"-" json:"-" param:"project"` - Task *Task `xorm:"-" json:"task"` + Task *Task `xorm:"-" json:"task" readOnly:"true" doc:"The task as it stands after the move, reflecting any done-state change."` web.Permissions `xorm:"-" json:"-"` web.CRUDable `xorm:"-" json:"-"` From 51e5c86f692d7e0bd304140701c0a7cf75bddafe Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:15:00 +0200 Subject: [PATCH 09/45] feat(api/v2): add kanban task-bucket moves on /api/v2 --- pkg/routes/api/v2/task_bucket.go | 69 ++++++++++++++ pkg/webtests/huma_task_bucket_test.go | 127 ++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 pkg/routes/api/v2/task_bucket.go create mode 100644 pkg/webtests/huma_task_bucket_test.go diff --git a/pkg/routes/api/v2/task_bucket.go b/pkg/routes/api/v2/task_bucket.go new file mode 100644 index 000000000..b07774b2f --- /dev/null +++ b/pkg/routes/api/v2/task_bucket.go @@ -0,0 +1,69 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskBucketRoutes wires the kanban task-bucket move onto the Huma API. +// +// TaskBucket exposes only Update, so the handler reuses handler.DoUpdate (its +// CanUpdate enforces write access on the bucket's project). The bucket and view +// come from the path; only the task id is read from the body. +func RegisterTaskBucketRoutes(api huma.API) { + tags := []string{"projects"} + + Register(api, huma.Operation{ + OperationID: "task-bucket-update", + Summary: "Place a task in a kanban bucket", + Description: "Moves a task into the given bucket of a project's kanban view. Requires write access to the project. " + + "Idempotent: re-sending the same bucket is a no-op. Side effects: moving a task into the view's done bucket marks it done (and out of it un-marks it); a repeating task moved into the done bucket is reopened and routed back to the default bucket instead. " + + "Moving a task into a bucket that is already at its task limit is rejected with 412. A bucket that does not resolve under the project and view in the path is rejected with 404.", + Method: http.MethodPut, + Path: "/projects/{project}/views/{view}/buckets/{bucket}/tasks", + Tags: tags, + }, taskBucketUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskBucketRoutes) } + +func taskBucketUpdate(ctx context.Context, in *struct { + ProjectID int64 `path:"project"` + ViewID int64 `path:"view"` + BucketID int64 `path:"bucket"` + Body models.TaskBucket +}) (*singleBody[models.TaskBucket], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tb := &in.Body + tb.ProjectID = in.ProjectID // URL wins over body + tb.ProjectViewID = in.ViewID // URL wins over body + tb.BucketID = in.BucketID // URL wins over body + if err := handler.DoUpdate(ctx, tb, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskBucket]{Body: tb}, nil +} diff --git a/pkg/webtests/huma_task_bucket_test.go b/pkg/webtests/huma_task_bucket_test.go new file mode 100644 index 000000000..e1623bf67 --- /dev/null +++ b/pkg/webtests/huma_task_bucket_test.go @@ -0,0 +1,127 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskBucketV2 covers PUT /projects/{project}/views/{view}/buckets/{bucket}/tasks. +// It drives the Echo+Huma stack directly (humaRequest/humaTokenFor) because the +// route is an action sub-path webHandlerTestV2's buildURL doesn't model. Fixtures +// (project 1, view 4): bucket 1 default, bucket 2 "Doing" limit 3 (full), bucket 3 done. +func TestTaskBucketV2(t *testing.T) { + const path = "/api/v2/projects/1/views/4/buckets/%d/tasks" + + t.Run("moves a task into a bucket", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 3 starts in bucket 2; move it into bucket 1 (neither full nor done). + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":3}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"task_id":3`) + + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 3, + "bucket_id": 1, + }, false) + }) + + t.Run("moving a task into the done bucket marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 3 is the done bucket on view 4; task 1 is not yet done. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 3), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":true`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 1, + "done": true, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 1, + "bucket_id": 3, + }, false) + }) + + t.Run("moving a task out of the done bucket un-marks it done", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 2 starts in bucket 3 (done) and is done; move it to bucket 1. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":2}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"done":false`) + + db.AssertExists(t, "tasks", map[string]interface{}{ + "id": 2, + "done": false, + }, false) + db.AssertExists(t, "task_buckets", map[string]interface{}{ + "task_id": 2, + "bucket_id": 1, + }, false) + }) + + t.Run("full bucket is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 2 already holds 3 tasks and has a limit of 3. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 2), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBucketLimitExceeded)) + }) + + t.Run("bucket on another view is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Bucket 4 lives on view 8 (project 2), so under view 4 / project 1 the + // permission check resolves the bucket's own view scoped by the path + // project and finds none → 404 before the move's own 400 can fire. + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 4), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectViewDoesNotExist)) + }) + + t.Run("no write access is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 has no access to project 1. + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, fmt.Sprintf(path, 1), `{"task_id":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} From d5bcbe39b4a11c0a60374293c7470e0464b3200f Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:26:55 +0200 Subject: [PATCH 10/45] docs(api/v2): tag project duplication fields for the v2 schema --- pkg/models/project_duplicate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 2ad0857ec..947b07d5a 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -33,10 +33,10 @@ type ProjectDuplicate struct { // The project id of the project to duplicate ProjectID int64 `json:"-" param:"projectid"` // The target parent project - ParentProjectID int64 `json:"parent_project_id,omitempty"` + ParentProjectID int64 `json:"parent_project_id,omitempty" doc:"The id of the project under which the duplicate should be created. Omit or 0 to place the copy at the top level; you need write access to the parent."` // The copied project - Project *Project `json:"duplicated_project,omitempty"` + Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."` web.Permissions `json:"-"` web.CRUDable `json:"-"` From 1aa9493bc316b6a5a0e39d1e845fff2df4cdb03a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:26:56 +0200 Subject: [PATCH 11/45] feat(api/v2): add project duplication on /api/v2 --- pkg/routes/api/v2/project_duplicate.go | 63 ++++++++++++ pkg/webtests/huma_project_duplicate_test.go | 107 ++++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 pkg/routes/api/v2/project_duplicate.go create mode 100644 pkg/webtests/huma_project_duplicate_test.go diff --git a/pkg/routes/api/v2/project_duplicate.go b/pkg/routes/api/v2/project_duplicate.go new file mode 100644 index 000000000..9fd23798f --- /dev/null +++ b/pkg/routes/api/v2/project_duplicate.go @@ -0,0 +1,63 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterProjectDuplicateRoutes wires the project-duplicate action onto the Huma API. +// +// ProjectDuplicate is a CRUDable Create, so the handler reuses handler.DoCreate +// (its CanCreate enforces access); the only custom part is taking ProjectID from +// the path rather than the request body. +func RegisterProjectDuplicateRoutes(api huma.API) { + tags := []string{"projects"} + + Register(api, huma.Operation{ + OperationID: "projects-duplicate", + Summary: "Duplicate a project", + Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds and user/team/link shares — into a new project owned by the authenticated user. The user needs read access to the source project, plus write access to the parent project when one is given. The copy is placed under parent_project_id (top level if omitted). Returns the duplicate in duplicated_project.", + Method: http.MethodPost, + Path: "/projects/{projectid}/duplicate", + Tags: tags, + }, projectsDuplicate) +} + +func init() { AddRouteRegistrar(RegisterProjectDuplicateRoutes) } + +func projectsDuplicate(ctx context.Context, in *struct { + ProjectID int64 `path:"projectid" doc:"The numeric id of the project to duplicate."` + Body models.ProjectDuplicate +}) (*singleBody[models.ProjectDuplicate], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + pd := &in.Body + pd.ProjectID = in.ProjectID + if err := handler.DoCreate(ctx, pd, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.ProjectDuplicate]{Body: pd}, nil +} diff --git a/pkg/webtests/huma_project_duplicate_test.go b/pkg/webtests/huma_project_duplicate_test.go new file mode 100644 index 000000000..bedabed0b --- /dev/null +++ b/pkg/webtests/huma_project_duplicate_test.go @@ -0,0 +1,107 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestProjectDuplicateV2 covers POST /projects/{projectid}/duplicate. It drives +// the Echo+Huma stack directly (humaRequest/humaTokenFor) because +// webHandlerTestV2's buildURL only models base[/{id}] paths, not action sub-paths. +func TestProjectDuplicateV2(t *testing.T) { + t.Run("duplicates an accessible project to the top level", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Duplicating copies the source project's task attachments, so the + // referenced fixture file must exist in the (memory) file store. + files.InitTestFileFixtures(t) + token := humaTokenFor(t, &testuser1) + + // Project 1 is owned by testuser1. + const sourceProjectID int64 = 1 + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"duplicated_project"`) + + var resp struct { + DuplicatedProject struct { + ID int64 `json:"id"` + Title string `json:"title"` + ParentProjectID int64 `json:"parent_project_id"` + } `json:"duplicated_project"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotZero(t, resp.DuplicatedProject.ID, "duplicated project should have an id") + assert.NotEqual(t, sourceProjectID, resp.DuplicatedProject.ID, "duplicated project must have a new id, not the source project's") + assert.Contains(t, resp.DuplicatedProject.Title, "duplicate") + assert.Zero(t, resp.DuplicatedProject.ParentProjectID, "top-level duplicate must have no parent") + }) + + t.Run("places the duplicate under parent_project_id from the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + files.InitTestFileFixtures(t) + token := humaTokenFor(t, &testuser1) + + // testuser1 owns project 1, so it may both read the source and create + // the copy underneath it. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{"parent_project_id":1}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + DuplicatedProject struct { + ID int64 `json:"id"` + ParentProjectID int64 `json:"parent_project_id"` + } `json:"duplicated_project"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotZero(t, resp.DuplicatedProject.ID) + assert.Equal(t, int64(1), resp.DuplicatedProject.ParentProjectID, "duplicate must land under the requested parent") + }) + + t.Run("nonexistent source project", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/99999/duplicate", `{}`, token, "") + // CanCreate loads the source via CanRead, which surfaces + // ErrProjectDoesNotExist (404) for a missing project rather than a 403. + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist), "body must surface ErrCodeProjectDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no read on source project is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot read project 1 (owned by testuser1, no share). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/duplicate", `{}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} From 711545e9f2c7f6dc74580bfa6b8a9f65a0c8fda2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:17:11 +0200 Subject: [PATCH 12/45] docs(api/v2): tag bulk task fields for the v2 schema --- pkg/models/bulk_task.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/models/bulk_task.go b/pkg/models/bulk_task.go index c103bc713..a0e294422 100644 --- a/pkg/models/bulk_task.go +++ b/pkg/models/bulk_task.go @@ -24,10 +24,10 @@ import ( // BulkTask represents a bulk task update payload. type BulkTask struct { - TaskIDs []int64 `json:"task_ids"` - Fields []string `json:"fields"` - Values *Task `json:"values"` - Tasks []*Task `json:"tasks,omitempty"` + TaskIDs []int64 `json:"task_ids" doc:"The ids of the tasks to update. The user needs write access to every project these tasks belong to, or the whole request is rejected."` + Fields []string `json:"fields" doc:"The names of the task fields to apply from values; only these fields are written, the rest of each task is left untouched."` + Values *Task `json:"values" doc:"The task carrying the values to set. Only the fields named in fields are read from it and applied to every task."` + Tasks []*Task `json:"tasks,omitempty" readOnly:"true" doc:"The updated tasks, returned in the response."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From 5c960fccd5b934d29f0c16b6873e3978b68543a3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:17:11 +0200 Subject: [PATCH 13/45] feat(api/v2): add bulk task updates on /api/v2 --- pkg/routes/api/v2/bulk_task.go | 61 ++++++++++++++++++++++++ pkg/webtests/huma_bulk_task_test.go | 74 +++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 pkg/routes/api/v2/bulk_task.go create mode 100644 pkg/webtests/huma_bulk_task_test.go diff --git a/pkg/routes/api/v2/bulk_task.go b/pkg/routes/api/v2/bulk_task.go new file mode 100644 index 000000000..be5e4b31e --- /dev/null +++ b/pkg/routes/api/v2/bulk_task.go @@ -0,0 +1,61 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterBulkTaskRoutes wires the bulk task update action onto the Huma API. +// +// BulkTask is a CRUDable Update, so the handler reuses handler.DoUpdate; its +// CanUpdate fans the write check out across every project the involved tasks +// belong to, so a single project the user can't write to rejects the request. +func RegisterBulkTaskRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-bulk-update", + Summary: "Bulk update tasks", + Description: "Applies the fields named in `fields` from `values` to every task in `task_ids`. The user needs write access to every project the involved tasks belong to; if write is missing on even one, the whole request is rejected and nothing is changed. Returns the updated tasks.", + Method: http.MethodPut, + Path: "/tasks/bulk", + Tags: tags, + }, tasksBulkUpdate) +} + +func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) } + +func tasksBulkUpdate(ctx context.Context, in *struct { + Body models.BulkTask +}) (*singleBody[models.BulkTask], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + bt := &in.Body + if err := handler.DoUpdate(ctx, bt, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.BulkTask]{Body: bt}, nil +} diff --git a/pkg/webtests/huma_bulk_task_test.go b/pkg/webtests/huma_bulk_task_test.go new file mode 100644 index 000000000..f61141541 --- /dev/null +++ b/pkg/webtests/huma_bulk_task_test.go @@ -0,0 +1,74 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestBulkTaskV2 covers PUT /tasks/bulk. It drives the Echo+Huma stack directly +// (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL only models +// base[/{id}] paths, not action sub-paths. +func TestBulkTaskV2(t *testing.T) { + t.Run("updates multiple tasks the user can write", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Tasks 1 and 2 both live in project 1, which testuser1 owns. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[1,2],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + db.AssertExists(t, "tasks", map[string]interface{}{"id": 1, "title": "bulkupdated"}, false) + db.AssertExists(t, "tasks", map[string]interface{}{"id": 2, "title": "bulkupdated"}, false) + }) + + t.Run("forbidden when missing write on one involved project", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 is in project 1 (owned), task 32 in project 3 (read-only share). + // CanUpdate fans the write check across both projects, so the whole + // request is rejected and neither task changes. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[1,32],"fields":["title"],"values":{"title":"shouldnothappen"}}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + + db.AssertMissing(t, "tasks", map[string]interface{}{"title": "shouldnothappen"}) + }) + + t.Run("empty task_ids is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/bulk", + `{"task_ids":[],"fields":["title"],"values":{"title":"bulkupdated"}}`, token, "") + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeBulkTasksNeedAtLeastOne), "body: %s", rec.Body.String()) + }) +} From da76d393d93623ea596226e7b4dd62b0bd8e8f9b Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:34:41 +0200 Subject: [PATCH 14/45] docs(api/v2): tag task relation fields for the v2 schema --- pkg/models/task_relation.go | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/models/task_relation.go b/pkg/models/task_relation.go index 58280c5ee..392ea2ef4 100644 --- a/pkg/models/task_relation.go +++ b/pkg/models/task_relation.go @@ -81,18 +81,19 @@ type TaskRelation struct { // The unique, numeric id of this relation. ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"` // The ID of the "base" task, the task which has a relation to another. - TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"` + TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the base task. Set from the URL path; ignored in the request body."` // The ID of the other task, the task which is being related. - OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask"` + OtherTaskID int64 `xorm:"bigint not null" json:"other_task_id" param:"otherTask" doc:"The id of the other task this relation points to."` // The kind of the relation. - RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind"` + // The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it. + RelationKind RelationKind `xorm:"varchar(50) not null" json:"relation_kind" param:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of relation, describing the direction from the base task to the other task (e.g. subtask, blocking, related). The inverse relation is created automatically."` CreatedByID int64 `xorm:"bigint not null" json:"-"` // The user who created this relation - CreatedBy *user.User `xorm:"-" json:"created_by"` + CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who created this relation."` // A timestamp when this label was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this relation was created. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From 2e02fe11acf060f7a697790f438e89f55a13fac4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:34:41 +0200 Subject: [PATCH 15/45] feat(api/v2): add task relations on /api/v2 --- pkg/routes/api/v2/task_relations.go | 94 +++++++++++ pkg/webtests/huma_task_relation_test.go | 197 ++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 pkg/routes/api/v2/task_relations.go create mode 100644 pkg/webtests/huma_task_relation_test.go diff --git a/pkg/routes/api/v2/task_relations.go b/pkg/routes/api/v2/task_relations.go new file mode 100644 index 000000000..eb9fa305f --- /dev/null +++ b/pkg/routes/api/v2/task_relations.go @@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskRelationRoutes wires task-relation create/delete onto the Huma API. +// +// Both operations reuse handler.DoCreate/DoDelete; CanCreate enforces write on +// the base task + read on the other task and rejects invalid kinds, CanDelete +// enforces write on the base task. The only custom part is mapping the path +// segments onto the model. +func RegisterTaskRelationRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-relations-create", + Summary: "Create a task relation", + Description: "Relates two tasks. The authenticated user needs write access to the base task (in the path) and at least read access to the other task; the two tasks need not share a project. The inverse relation is created automatically (e.g. a subtask relation also stores the parenttask relation on the other task). Subtask/parenttask chains that would form a cycle are rejected.", + Method: http.MethodPost, + Path: "/tasks/{task}/relations", + Tags: tags, + }, tasksRelationsCreate) + + Register(api, huma.Operation{ + OperationID: "tasks-relations-delete", + Summary: "Delete a task relation", + Description: "Removes the relation identified by the base task, relation kind and other task. The automatically created inverse relation is removed as well. The authenticated user needs write access to the base task.", + Method: http.MethodDelete, + Path: "/tasks/{task}/relations/{relationKind}/{otherTask}", + Tags: tags, + }, tasksRelationsDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskRelationRoutes) } + +func tasksRelationsCreate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the base task to relate from."` + Body models.TaskRelation +}) (*singleBody[models.TaskRelation], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + rel := &in.Body + rel.TaskID = in.TaskID // URL wins over body + if err := handler.DoCreate(ctx, rel, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskRelation]{Body: rel}, nil +} + +// The relationKind enum mirrors models.TaskRelation.RelationKind's tag (see the sync note there). +func tasksRelationsDelete(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the base task."` + RelationKind models.RelationKind `path:"relationKind" enum:"subtask,parenttask,related,duplicateof,duplicates,blocking,blocked,precedes,follows,copiedfrom,copiedto" doc:"The kind of the relation to remove."` + OtherTaskID int64 `path:"otherTask" doc:"The numeric id of the other task in the relation."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + rel := &models.TaskRelation{ + TaskID: in.TaskID, + RelationKind: in.RelationKind, + OtherTaskID: in.OtherTaskID, + } + if err := handler.DoDelete(ctx, rel, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_task_relation_test.go b/pkg/webtests/huma_task_relation_test.go new file mode 100644 index 000000000..65166402c --- /dev/null +++ b/pkg/webtests/huma_task_relation_test.go @@ -0,0 +1,197 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskRelationV2 covers POST /tasks/{task}/relations and +// DELETE /tasks/{task}/relations/{relationKind}/{otherTask}. It drives the +// Echo+Huma stack directly (humaRequest/humaTokenFor) because the action +// sub-paths aren't modelled by webHandlerTestV2's buildURL. Coverage mirrors +// the v1 model matrix in pkg/models/task_relation_test.go. +func TestTaskRelationV2(t *testing.T) { + t.Run("Create", func(t *testing.T) { + t.Run("creates forward and inverse rows", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":2,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"relation_kind":"subtask"`) + assert.Contains(t, rec.Body.String(), `"task_id":1`) + assert.Contains(t, rec.Body.String(), `"other_task_id":2`) + + // Create must store both directions: the forward subtask and the + // automatically derived inverse parenttask. + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 2, + "relation_kind": models.RelationKindSubtask, + "created_by_id": 1, + }, false) + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 2, + "other_task_id": 1, + "relation_kind": models.RelationKindParenttask, + "created_by_id": 1, + }, false) + }) + + t.Run("path task id wins over body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task_id in the body is ignored; the row is created for the path task. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"task_id":999,"other_task_id":2,"relation_kind":"related"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + db.AssertExists(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 2, + "relation_kind": models.RelationKindRelated, + }, false) + }) + + t.Run("cycle is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task 29 is already a subtask of task 1 (fixture); making task 1 a + // subtask of task 29 would close the loop. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/29/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusConflict, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskRelationCycle)) + }) + + t.Run("same task is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":1,"relation_kind":"related"}`, token, "") + require.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationTasksCannotBeTheSame)) + }) + + t.Run("invalid relation kind in body is rejected by the enum", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // relation_kind carries an enum constraint, so Huma rejects an unknown + // kind with 422 before the handler runs (consistent with the delete path). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + `{"other_task_id":2,"relation_kind":"bogus"}`, token, "") + require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("nonexistent base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/999999/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist)) + }) + + t.Run("forbidden - no write on base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // task 15 is read-only for user1, so CanCreate (needs write on base) denies. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/15/relations", + `{"other_task_id":1,"relation_kind":"subtask"}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("removes forward and inverse rows", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Fixture relation 1: task 1 -subtask-> task 29, with the inverse + // parenttask row (task 29 -> task 1). + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/29", "", token, "") + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + assert.Empty(t, rec.Body.String()) + + db.AssertMissing(t, "task_relations", map[string]interface{}{ + "task_id": 1, + "other_task_id": 29, + "relation_kind": models.RelationKindSubtask, + }) + db.AssertMissing(t, "task_relations", map[string]interface{}{ + "task_id": 29, + "other_task_id": 1, + "relation_kind": models.RelationKindParenttask, + }) + }) + + t.Run("invalid relation kind in path is rejected by the enum", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // The path param carries an enum constraint, so Huma rejects an unknown + // kind with 422 before the handler runs. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/bogus/29", "", token, "") + require.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("nonexistent relation", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/relations/subtask/2", "", token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeRelationDoesNotExist)) + }) + + t.Run("forbidden - no write on base task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Fixture relation 7: task 41 -subtask-> task 43, owned by user15 in + // project 36, which user1 cannot access — CanDelete denies. + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/41/relations/subtask/43", "", token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + }) +} From f5e7e9ddde099b40a7054705525e268224b6e88b Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 22:05:57 +0200 Subject: [PATCH 16/45] docs(api/v2): tag reaction fields for the v2 schema --- pkg/models/reaction.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/models/reaction.go b/pkg/models/reaction.go index 712e144a2..9567cfbc9 100644 --- a/pkg/models/reaction.go +++ b/pkg/models/reaction.go @@ -38,7 +38,7 @@ type Reaction struct { ID int64 `xorm:"autoincr not null unique pk" json:"-" param:"reaction"` // The user who reacted - User *user.User `xorm:"-" json:"user" valid:"-"` + User *user.User `xorm:"-" json:"user" valid:"-" readOnly:"true" doc:"The user who reacted. Set by the server from the authenticated user; ignored on write."` UserID int64 `xorm:"bigint not null INDEX" json:"-"` // The id of the entity you're reacting to @@ -48,10 +48,10 @@ type Reaction struct { EntityKindString string `xorm:"-" json:"-" param:"entitykind"` // The actual reaction. This can be any valid utf character or text, up to a length of 20. - Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required"` + Value string `xorm:"varchar(20) not null INDEX" json:"value" valid:"required" maxLength:"20" doc:"The reaction itself: any UTF text up to 20 characters, e.g. an emoji."` // A timestamp when this reaction was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this reaction was created. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From 1e82c62ff76f9182f5d97f1f2c2b10a988ceabe7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 22:05:57 +0200 Subject: [PATCH 17/45] feat(api/v2): add reactions on /api/v2 --- pkg/routes/api/v2/reactions.go | 135 +++++++++++++++++++++++++++++ pkg/webtests/huma_reaction_test.go | 103 ++++++++++++++++++++++ 2 files changed, 238 insertions(+) create mode 100644 pkg/routes/api/v2/reactions.go create mode 100644 pkg/webtests/huma_reaction_test.go diff --git a/pkg/routes/api/v2/reactions.go b/pkg/routes/api/v2/reactions.go new file mode 100644 index 000000000..722b2615a --- /dev/null +++ b/pkg/routes/api/v2/reactions.go @@ -0,0 +1,135 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// {entitykind} stays a string: the model derives the numeric EntityKind from +// it and rejects unknown kinds. The enum tag (repeated on the create/delete +// inputs) makes Huma reject anything else with a 422 before the handler runs; +// keep the values in sync with models.Reaction.setEntityKindFromString. +type reactionPathParams struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` +} + +// Reactions list as a map keyed by reaction value, not a slice, so it does not +// fit the Paginated envelope. +type reactionListBody struct { + Body models.ReactionMap +} + +func RegisterReactionRoutes(api huma.API) { + tags := []string{"reactions"} + + Register(api, huma.Operation{ + OperationID: "reactions-list", + Summary: "List reactions for an entity", + Description: "Returns every reaction on the entity, grouped as a map keyed by reaction value; each value maps to the users who reacted with it. Requires read access to the entity. Not paginated.", + Method: http.MethodGet, + Path: "/{entitykind}/{entityid}/reactions", + Tags: tags, + }, reactionsList) + + Register(api, huma.Operation{ + OperationID: "reactions-create", + Summary: "React to an entity", + Description: "Adds the authenticated user's reaction to the entity. Requires write access. No-op if the same reaction already exists.", + Method: http.MethodPost, + Path: "/{entitykind}/{entityid}/reactions", + Tags: tags, + }, reactionsCreate) + + Register(api, huma.Operation{ + OperationID: "reactions-delete", + Summary: "Remove a reaction from an entity", + Description: "Removes the authenticated user's own reaction from the entity. The reaction to remove is named in the body (there is no per-reaction id), so this is a POST with a body rather than a DELETE. Requires write access.", + Method: http.MethodPost, + Path: "/{entitykind}/{entityid}/reactions/delete", + Tags: tags, + DefaultStatus: http.StatusOK, + }, reactionsDelete) +} + +func init() { AddRouteRegistrar(RegisterReactionRoutes) } + +func reactionsList(ctx context.Context, in *reactionPathParams) (*reactionListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &models.Reaction{EntityID: in.EntityID, EntityKindString: in.EntityKind} + result, _, _, err := handler.DoReadAll(ctx, r, a, "", 1, -1) + if err != nil { + return nil, translateDomainError(err) + } + reactions, ok := result.(models.ReactionMap) + if !ok { + return nil, fmt.Errorf("reactions.ReadAll returned unexpected type %T (expected models.ReactionMap)", result) + } + if reactions == nil { + reactions = models.ReactionMap{} + } + return &reactionListBody{Body: reactions}, nil +} + +// Path params are flattened (not via the embedded reactionPathParams) because +// Huma fails to bind an embedded path-param struct when the input also has a Body. +func reactionsCreate(ctx context.Context, in *struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` + Body models.Reaction +}) (*singleBody[models.Reaction], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &in.Body + r.EntityID = in.EntityID + r.EntityKindString = in.EntityKind + if err := handler.DoCreate(ctx, r, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Reaction]{Body: r}, nil +} + +func reactionsDelete(ctx context.Context, in *struct { + EntityKind string `path:"entitykind" enum:"tasks,comments" doc:"The kind of entity being reacted to. Either tasks or comments (task comments)."` + EntityID int64 `path:"entityid" doc:"The numeric id of the entity being reacted to."` + Body models.Reaction +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + r := &in.Body + r.EntityID = in.EntityID + r.EntityKindString = in.EntityKind + if err := handler.DoDelete(ctx, r, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_reaction_test.go b/pkg/webtests/huma_reaction_test.go new file mode 100644 index 000000000..56eac82e5 --- /dev/null +++ b/pkg/webtests/huma_reaction_test.go @@ -0,0 +1,103 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// reactionMapFromBody decodes the v2 reactions list body — a map keyed by +// reaction value, each value the list of users who reacted with it. +func reactionMapFromBody(t *testing.T, body []byte) map[string][]struct { + ID int64 `json:"id"` + Username string `json:"username"` +} { + t.Helper() + var m map[string][]struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + require.NoError(t, json.Unmarshal(body, &m), "list body must be a reaction map: %s", string(body)) + return m +} + +// TestHumaReaction exercises the v2 reaction surface, mirroring the v1 +// model-level matrix in pkg/models/reaction_test.go. Fixture reactions.yml +// seeds reaction #1: user1 reacted "👋" on task #1. +func TestHumaReaction(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("List returns the map with the reacting user", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + require.Len(t, m["👋"], 1, "fixture reaction must be present; body: %s", rec.Body.String()) + assert.Equal(t, int64(1), m["👋"][0].ID, "the reacting user is user1") + }) + + t.Run("Create then list reflects the new reaction", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions", `{"value":"🦙"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"value":"🦙"`) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + require.Len(t, m["🦙"], 1, "created reaction must appear in the list; body: %s", rec.Body.String()) + assert.Equal(t, int64(1), m["🦙"][0].ID) + }) + + t.Run("Delete removes the reaction", func(t *testing.T) { + // Remove the fixture reaction (user1's "👋" on task #1) and confirm via a follow-up list. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/reactions/delete", `{"value":"👋"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "delete is POST-with-body returning 200; body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/reactions", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + m := reactionMapFromBody(t, rec.Body.Bytes()) + assert.NotContains(t, m, "👋", "deleted reaction must be gone; body: %s", rec.Body.String()) + }) + + t.Run("Invalid entitykind is rejected", func(t *testing.T) { + // The enum tag on the path param makes Huma reject unknown kinds before the handler runs. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/loremipsum/1/reactions", "", token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Forbidden - no access to the entity", func(t *testing.T) { + // Task #34 lives in a private project user1 cannot see. + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/reactions", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Nonexistent entity", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/9999999/reactions", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Create forbidden - no access to the entity", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/34/reactions", `{"value":"🦙"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} From 12f290905aa43b0bc2e808c71e5ba8077a0215c7 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Tue, 9 Jun 2026 23:32:10 +0000 Subject: [PATCH 18/45] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 5 +++-- pkg/swagger/swagger.json | 5 +++-- pkg/swagger/swagger.yaml | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 8d094f3ae..874d9ec09 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -9876,7 +9876,8 @@ const docTemplate = `{ }, "value": { "description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.", - "type": "string" + "type": "string", + "maxLength": 20 } } }, @@ -10373,7 +10374,7 @@ const docTemplate = `{ "type": "integer" }, "relation_kind": { - "description": "The kind of the relation.", + "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.", "allOf": [ { "$ref": "#/definitions/models.RelationKind" diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index b8f10bda2..1d9d15f49 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -9868,7 +9868,8 @@ }, "value": { "description": "The actual reaction. This can be any valid utf character or text, up to a length of 20.", - "type": "string" + "type": "string", + "maxLength": 20 } } }, @@ -10365,7 +10366,7 @@ "type": "integer" }, "relation_kind": { - "description": "The kind of the relation.", + "description": "The kind of the relation.\nThe enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it.", "allOf": [ { "$ref": "#/definitions/models.RelationKind" diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 2018d52ca..6bc114729 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -741,6 +741,7 @@ definitions: value: description: The actual reaction. This can be any valid utf character or text, up to a length of 20. + maxLength: 20 type: string type: object models.ReactionMap: @@ -1134,7 +1135,9 @@ definitions: relation_kind: allOf: - $ref: '#/definitions/models.RelationKind' - description: The kind of the relation. + description: |- + The kind of the relation. + The enum list must stay in sync with RelationKind.isValid() (RelationKindUnknown excluded); the v2 delete route param repeats it. task_id: description: The ID of the "base" task, the task which has a relation to another. type: integer From 8502c541a638693830e5da2025bd4aa5ebaf642d Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Wed, 10 Jun 2026 00:33:40 +0000 Subject: [PATCH 19/45] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/de-DE.json | 33 +++++++++++++++++++++++++++- frontend/src/i18n/lang/de-swiss.json | 33 +++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/frontend/src/i18n/lang/de-DE.json b/frontend/src/i18n/lang/de-DE.json index 8a71cefb1..393bc643d 100644 --- a/frontend/src/i18n/lang/de-DE.json +++ b/frontend/src/i18n/lang/de-DE.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "JJJJ/MM/TT" }, "timeFormat": "Zeitformat", + "timeTrackingDefaultStart": "Startzeit für die Zeiterfassung", "timeFormatOptions": { "12h": "12 Stunden (AM/PM)", "24h": "24 Stunden (HH:mm)" @@ -781,7 +782,10 @@ "closeDialog": "Dialog schließen", "closeQuickActions": "Schnellaktionen schließen", "skipToContent": "Überspringen und zum Hauptinhalt gehen", - "sortBy": "Sortieren nach" + "sortBy": "Sortieren nach", + "dateRange": "Zeitraum", + "notSet": "Nicht festgelegt", + "user": "Benutzer:in" }, "input": { "projectColor": "Projektfarbe", @@ -991,6 +995,7 @@ "repeatAfter": "Wiederholung setzen", "percentDone": "Fortschritt einstellen", "attachments": "Anhänge hinzufügen", + "timeTracking": "Zeit erfassen", "relatedTasks": "Beziehung hinzufügen", "moveProject": "Verschieben", "duplicate": "Duplizieren", @@ -1460,6 +1465,32 @@ "frontendVersion": "Frontend-Version: {version}", "apiVersion": "API-Version: {version}" }, + "timeTracking": { + "title": "Zeiterfassung", + "stop": "Timer stoppen", + "logTime": "Zeit buchen", + "editEntry": "Eintrag bearbeiten", + "form": { + "task": "Aufgabe", + "taskSearch": "Nach einer Aufgabe suchen…", + "commentPlaceholder": "Woran hast du gearbeitet?", + "save": "Speichern", + "startTimer": "Timer starten", + "update": "Eintrag aktualisieren", + "smartFill": "Vom letzten Eintrag ausfüllen" + }, + "list": { + "emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.", + "emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.", + "total": "Gesamt", + "time": "Uhrzeit", + "duration": "Dauer" + }, + "browse": { + "selectRange": "Bereich wählen", + "userSearch": "Nach einer:m Benutzer:in suchen…" + } + }, "time": { "units": { "seconds": "Sekunde|Sekunden", diff --git a/frontend/src/i18n/lang/de-swiss.json b/frontend/src/i18n/lang/de-swiss.json index a76780342..6ae6e7160 100644 --- a/frontend/src/i18n/lang/de-swiss.json +++ b/frontend/src/i18n/lang/de-swiss.json @@ -172,6 +172,7 @@ "yyyy/mm/dd": "JJJJ/MM/TT" }, "timeFormat": "Zeitformat", + "timeTrackingDefaultStart": "Startzeit für die Zeiterfassung", "timeFormatOptions": { "12h": "12 Stunden (AM/PM)", "24h": "24 Stunden (HH:mm)" @@ -781,7 +782,10 @@ "closeDialog": "Dialog schließen", "closeQuickActions": "Schnellaktionen schließen", "skipToContent": "Überspringen und zum Hauptinhalt gehen", - "sortBy": "Sortieren nach" + "sortBy": "Sortieren nach", + "dateRange": "Zeitraum", + "notSet": "Nicht festgelegt", + "user": "Benutzer:in" }, "input": { "projectColor": "Projektfarbe", @@ -991,6 +995,7 @@ "repeatAfter": "Wiederholung setzen", "percentDone": "Fortschritt einstellen", "attachments": "Anhänge hinzufügen", + "timeTracking": "Zeit erfassen", "relatedTasks": "Beziehung hinzufügen", "moveProject": "Verschieben", "duplicate": "Duplizieren", @@ -1460,6 +1465,32 @@ "frontendVersion": "Frontend-Version: {version}", "apiVersion": "API-Version: {version}" }, + "timeTracking": { + "title": "Zeiterfassung", + "stop": "Timer stoppen", + "logTime": "Zeit buchen", + "editEntry": "Eintrag bearbeiten", + "form": { + "task": "Aufgabe", + "taskSearch": "Nach einer Aufgabe suchen…", + "commentPlaceholder": "Woran hast du gearbeitet?", + "save": "Speichern", + "startTimer": "Timer starten", + "update": "Eintrag aktualisieren", + "smartFill": "Vom letzten Eintrag ausfüllen" + }, + "list": { + "emptyTask": "Noch keine Zeit für diese Aufgabe getrackt.", + "emptyFiltered": "Keine Zeiterfassung innerhalb der ausgewählten Filter.", + "total": "Gesamt", + "time": "Uhrzeit", + "duration": "Dauer" + }, + "browse": { + "selectRange": "Bereich wählen", + "userSearch": "Nach einer:m Benutzer:in suchen…" + } + }, "time": { "units": { "seconds": "Sekunde|Sekunden", From 6d505e360bdbca258bbc946a355fe27aa8661809 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 22:15:07 +0000 Subject: [PATCH 20/45] chore(deps): update dev-dependencies to v40.10.3 --- desktop/package.json | 2 +- desktop/pnpm-lock.yaml | 99 ++++++++++++++++-------------------------- 2 files changed, 38 insertions(+), 63 deletions(-) diff --git a/desktop/package.json b/desktop/package.json index 4a2b83f75..765d91054 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -61,7 +61,7 @@ } }, "devDependencies": { - "electron": "40.10.2", + "electron": "40.10.3", "electron-builder": "26.15.2", "unzipper": "0.12.3" }, diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 7b7b94c6c..77a74681b 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -21,8 +21,8 @@ importers: version: 5.2.1 devDependencies: electron: - specifier: 40.10.2 - version: 40.10.2 + specifier: 40.10.3 + version: 40.10.3 electron-builder: specifier: 26.15.2 version: 26.15.2(electron-builder-squirrel-windows@24.13.3) @@ -39,6 +39,10 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@electron-internal/extract-zip@1.0.2': + resolution: {integrity: sha512-VJuNETNPEhrmQEZezeTZO5TZMV+dobBRyJ7zHjGJWIhMS7m7W1UeClt69u4hkUxv9ZZVxuli/E9Yvc4gDNHGsg==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -48,14 +52,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.2.1': resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==} engines: {node: '>= 10.0.0'} @@ -169,9 +173,6 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} - '@types/yauzl@2.10.3': - resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} - '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -532,9 +533,9 @@ packages: electron-publish@26.15.1: resolution: {integrity: sha512-BMgMHOyexWn0UnOC+Afffw0DMrr0yfLp4U8YsLXwoJ3Da7LS7WUnz21teYZqO0gaApE1KgsjREWmbPqvF5JcPg==} - electron@40.10.2: - resolution: {integrity: sha512-Xj3Hy0Imbu4g0gDIW55w/jJYz94nMO2JRSGYA3LyAn5SwaERCelgZrA21vfH+Bi//SWAWQXddHsMwCqauyMT8g==} - engines: {node: '>= 12.20.55'} + electron@40.10.3: + resolution: {integrity: sha512-DdWRsHm4j5wH9TMcfnB2Dqx44G/6BgLKSG/oeRe9kS60pfqCUwzUkHk0ClwvZzBVXtJ1kcdkHVRrJsl1ooKp+g==} + engines: {node: '>= 22.12.0'} hasBin: true emoji-regex@8.0.0: @@ -554,6 +555,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} @@ -598,11 +603,6 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} - extract-zip@2.0.1: - resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} - engines: {node: '>= 10.17.0'} - hasBin: true - fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -612,9 +612,6 @@ packages: fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} - fd-slicer@1.1.0: - resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1046,9 +1043,6 @@ packages: resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} engines: {node: '>=12', npm: '>=6'} - pend@1.2.0: - resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - picomatch@4.0.4: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} @@ -1355,6 +1349,10 @@ packages: resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==} engines: {node: '>=18.17'} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + universalify@0.1.2: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} @@ -1435,9 +1433,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - yauzl@2.10.0: - resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1455,6 +1450,8 @@ snapshots: ajv: 6.14.0 ajv-keywords: 3.5.2(ajv@6.14.0) + '@electron-internal/extract-zip@1.0.2': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -1467,7 +1464,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -1481,17 +1478,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.1 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 7.27.2 transitivePeerDependencies: - supports-color @@ -1666,11 +1662,6 @@ snapshots: dependencies: '@types/node': 24.10.9 - '@types/yauzl@2.10.3': - dependencies: - '@types/node': 24.10.9 - optional: true - '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': {} @@ -2187,11 +2178,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@40.10.2: + electron@40.10.3: dependencies: - '@electron/get': 2.0.3 + '@electron-internal/extract-zip': 1.0.2 + '@electron/get': 5.0.0 '@types/node': 24.10.9 - extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -2207,6 +2198,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + err-code@2.0.3: {} es-define-property@1.0.1: {} @@ -2271,26 +2264,12 @@ snapshots: transitivePeerDependencies: - supports-color - extract-zip@2.0.1: - dependencies: - debug: 4.4.3 - get-stream: 5.2.0 - yauzl: 2.10.0 - optionalDependencies: - '@types/yauzl': 2.10.3 - transitivePeerDependencies: - - supports-color - fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} fast-uri@3.1.2: {} - fd-slicer@1.1.0: - dependencies: - pend: 1.2.0 - fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -2728,8 +2707,6 @@ snapshots: pe-library@0.4.1: {} - pend@1.2.0: {} - picomatch@4.0.4: {} pkijs@3.4.0: @@ -3082,6 +3059,9 @@ snapshots: undici@6.26.0: {} + undici@7.27.2: + optional: true + universalify@0.1.2: {} universalify@2.0.1: {} @@ -3160,11 +3140,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - yauzl@2.10.0: - dependencies: - buffer-crc32: 0.2.13 - fd-slicer: 1.1.0 - yocto-queue@0.1.0: {} zip-stream@4.1.1: From e16d12023684e05d546f8f3508678c1b136e7d97 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 11:00:20 +0200 Subject: [PATCH 21/45] fix(time-tracking): cap smart-fill start at now so the range is never inverted Smart-fill set the From time to the configured default start (09:00) when there was no recent entry to continue from. Before that time of day the default lands in the future, after the To time of now, producing an inverted range the backend rejects (end_time before start_time). The save then failed silently and the entry never appeared. This surfaced as a flaky time-tracking e2e suite: the smart-fill specs failed only when CI happened to run before 09:00 UTC. --- frontend/src/helpers/time/smartFillStart.test.ts | 10 ++++++++++ frontend/src/helpers/time/smartFillStart.ts | 8 ++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/frontend/src/helpers/time/smartFillStart.test.ts b/frontend/src/helpers/time/smartFillStart.test.ts index e623125cf..e48caf130 100644 --- a/frontend/src/helpers/time/smartFillStart.test.ts +++ b/frontend/src/helpers/time/smartFillStart.test.ts @@ -44,4 +44,14 @@ describe('smartFillStart', () => { it('falls back to 09:00 when no default is configured', () => { expect(smartFillStart([], '', now)).toEqual(new Date('2026-06-07T09:00:00')) }) + + it('caps the default start at now when it would be in the future (before 09:00)', () => { + const beforeNine = new Date('2026-06-07T07:30:00') + expect(smartFillStart([], '09:00', beforeNine)).toEqual(beforeNine) + }) + + it('caps a future last-entry end at now', () => { + const entries = [entry(new Date('2026-06-07T16:00:00'), new Date('2026-06-07T17:00:00'))] + expect(smartFillStart(entries, '09:00', now)).toEqual(now) + }) }) diff --git a/frontend/src/helpers/time/smartFillStart.ts b/frontend/src/helpers/time/smartFillStart.ts index c131c1d0e..0a84a7eea 100644 --- a/frontend/src/helpers/time/smartFillStart.ts +++ b/frontend/src/helpers/time/smartFillStart.ts @@ -5,16 +5,20 @@ import type {ITimeEntry} from '@/modelTypes/ITimeEntry' // continue from, fall back to the user's configured default start (HH:MM) on // the given day. export function smartFillStart(recentEntries: ITimeEntry[], defaultStart: string, now: Date): Date { + // The filled range ends at now, so a start after now would be inverted (and + // rejected on save). Cap at now — e.g. the 09:00 fallback before 9am. + const cap = (start: Date) => (start.getTime() > now.getTime() ? new Date(now) : start) + const lastEnd = recentEntries .map(entry => entry.endTime) .filter((end): end is Date => end !== null) .sort((a, b) => b.getTime() - a.getTime())[0] if (lastEnd !== undefined) { - return new Date(lastEnd) + return cap(new Date(lastEnd)) } const [hours, minutes] = (defaultStart || '09:00').split(':').map(Number) const start = new Date(now) start.setHours(hours || 0, minutes || 0, 0, 0) - return start + return cap(start) } From a6a073329f70dccc70cb39d052ad06a9e16936f8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:06:50 +0200 Subject: [PATCH 22/45] docs(api/v2): tag task position fields for the v2 schema --- pkg/models/task_position.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go index 325033207..07c2839cc 100644 --- a/pkg/models/task_position.go +++ b/pkg/models/task_position.go @@ -33,9 +33,9 @@ const MinPositionSpacing = 0.01 type TaskPosition struct { // The ID of the task this position is for - TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"` + TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task" readOnly:"true" doc:"The numeric id of the task this position belongs to. Taken from the URL; ignored in the request body."` // The project view this task is related to - ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id" doc:"The id of the project view this position applies to. Positions are stored per view, so the same task has an independent position in each of its project's views."` // The position of the task - any task project can be sorted as usual by this parameter. // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). @@ -44,7 +44,7 @@ type TaskPosition struct { // which also leaves a lot of room for rearranging and sorting later. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. - Position float64 `xorm:"double not null" json:"position"` + Position float64 `xorm:"double not null" json:"position" doc:"The task's sort position within the view, as a float so a task can be placed between any two others. To drop a task between two neighbours, set this to their midpoint. Values below the minimum spacing trigger a server-side recalculation of all positions in the view, so the stored value may differ from what you sent."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From 25a294d7bc21729ac189d38313428f1abd55a689 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:06:50 +0200 Subject: [PATCH 23/45] feat(api/v2): add task position updates on /api/v2 --- pkg/routes/api/v2/task_position.go | 63 +++++++++++++++++ pkg/webtests/huma_task_position_test.go | 94 +++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 pkg/routes/api/v2/task_position.go create mode 100644 pkg/webtests/huma_task_position_test.go diff --git a/pkg/routes/api/v2/task_position.go b/pkg/routes/api/v2/task_position.go new file mode 100644 index 000000000..13a7e3af8 --- /dev/null +++ b/pkg/routes/api/v2/task_position.go @@ -0,0 +1,63 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterTaskPositionRoutes wires the task-position update onto the Huma API. +// +// Setting a position is a plain CRUDable Update, so the handler reuses +// handler.DoUpdate (its CanUpdate delegates to the task's CanUpdate); the only +// custom part is taking TaskID from the path rather than the request body. +func RegisterTaskPositionRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-position-update", + Summary: "Set a task's position in a view", + Description: "Sets where a task sorts within one of its project's views. The position is per view, so this only affects the view named by project_view_id. Requires write access to the task. Positions below the minimum spacing make the server recalculate every position in the view, so the returned value may differ from the one sent.", + Method: http.MethodPut, + Path: "/tasks/{task}/position", + Tags: tags, + }, tasksPositionUpdate) +} + +func init() { AddRouteRegistrar(RegisterTaskPositionRoutes) } + +func tasksPositionUpdate(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The numeric id of the task whose position to set."` + Body models.TaskPosition +}) (*singleBody[models.TaskPosition], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + tp := &in.Body + tp.TaskID = in.TaskID // URL wins over body + if err := handler.DoUpdate(ctx, tp, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.TaskPosition]{Body: tp}, nil +} diff --git a/pkg/webtests/huma_task_position_test.go b/pkg/webtests/huma_task_position_test.go new file mode 100644 index 000000000..da10768e4 --- /dev/null +++ b/pkg/webtests/huma_task_position_test.go @@ -0,0 +1,94 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestTaskPositionV2 covers PUT /tasks/{task}/position. It drives the Echo+Huma +// stack directly (humaRequest/humaTokenFor) because webHandlerTestV2's buildURL +// only models base[/{id}] paths, not action sub-paths. +func TestTaskPositionV2(t *testing.T) { + t.Run("updates the position of a writable task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Task 1 lives in project 1, which testuser1 owns; view 1 belongs to project 1. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":256}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID, "task id is taken from the URL") + assert.Equal(t, int64(1), resp.ProjectViewID) + assert.InDelta(t, 256.0, resp.Position, 0) + }) + + t.Run("path task id wins over the body", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Body names task 2, URL names task 1; the URL must win. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"task_id":2,"project_view_id":1,"position":300}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp models.TaskPosition + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, int64(1), resp.TaskID) + }) + + t.Run("nonexistent task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/99999/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeTaskDoesNotExist), "body must surface ErrCodeTaskDoesNotExist; body: %s", rec.Body.String()) + }) + + t.Run("no access to the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // testuser15 cannot access task 1 (project 1, owned by testuser1). + token := humaTokenFor(t, &testuser15) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/1/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("read but no write on the task is forbidden", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // Task 32 lives in project 3, on which testuser1 has read-only access. + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/32/position", `{"project_view_id":1,"position":1}`, token, "") + require.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} From 0e0ececa2dc755fbc06e14ed3f01ac0d8e1810e2 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:10:37 +0200 Subject: [PATCH 24/45] docs(api/v2): tag bulk label fields for the v2 schema --- pkg/models/label_task.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index 312f5d027..024b0e039 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -415,7 +415,7 @@ func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Lab // LabelTaskBulk is a helper struct to update a bunch of labels at once type LabelTaskBulk struct { // All labels you want to update at once. - Labels []*Label `json:"labels"` + Labels []*Label `json:"labels" doc:"The complete set of labels the task should have after the call. Any label currently on the task that is not in this list is removed; any label in the list that is not yet on the task is added. You must be able to see every label you attach."` TaskID int64 `json:"-" param:"projecttask"` web.CRUDable `json:"-"` From 328de89c0b69fd7ee9ba32b0b4fed9d677a94004 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 21:10:41 +0200 Subject: [PATCH 25/45] feat(api/v2): add bulk label replacement on /api/v2 --- pkg/routes/api/v2/label_task_bulk.go | 62 ++++++++++++ pkg/webtests/huma_label_task_bulk_test.go | 117 ++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 pkg/routes/api/v2/label_task_bulk.go create mode 100644 pkg/webtests/huma_label_task_bulk_test.go diff --git a/pkg/routes/api/v2/label_task_bulk.go b/pkg/routes/api/v2/label_task_bulk.go new file mode 100644 index 000000000..82f837540 --- /dev/null +++ b/pkg/routes/api/v2/label_task_bulk.go @@ -0,0 +1,62 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// RegisterLabelTaskBulkRoutes wires the bulk label-replacement action onto the +// Huma API. The model op is a CRUDable Create (handler.DoCreate, whose +// CanCreate enforces write access to the task), but the verb is PUT because the +// operation replaces the task's whole label set — the idempotent PUT semantics +// describe it more honestly than POST. +func RegisterLabelTaskBulkRoutes(api huma.API) { + tags := []string{"labels"} + + Register(api, huma.Operation{ + OperationID: "task-labels-bulk-replace", + Summary: "Replace all labels on a task", + Description: "Sets the task's labels to exactly the provided list: labels not in the list are removed, missing ones are added, unchanged ones are left alone. Requires write access to the task, and you must be able to see every label you attach. Returns the resulting label set.", + Method: http.MethodPut, + Path: "/tasks/{projecttask}/labels/bulk", + Tags: tags, + }, labelTasksBulkReplace) +} + +func init() { AddRouteRegistrar(RegisterLabelTaskBulkRoutes) } + +func labelTasksBulkReplace(ctx context.Context, in *struct { + TaskID int64 `path:"projecttask" doc:"The numeric id of the task whose labels to replace."` + Body models.LabelTaskBulk +}) (*singleBody[models.LabelTaskBulk], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + in.Body.TaskID = in.TaskID // parent from the path, not the body + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.LabelTaskBulk]{Body: &in.Body}, nil +} diff --git a/pkg/webtests/huma_label_task_bulk_test.go b/pkg/webtests/huma_label_task_bulk_test.go new file mode 100644 index 000000000..3ee42e45f --- /dev/null +++ b/pkg/webtests/huma_label_task_bulk_test.go @@ -0,0 +1,117 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestLabelTaskBulk_V2 ports the v1 bulk-replace matrix +// (pkg/webtests/label_task_test.go) onto PUT /api/v2/tasks/{projecttask}/labels/bulk. +// The body is the full target label set; the call adds missing labels and +// removes any not listed. +// +// Permission topology for testuser1 (see pkg/db/fixtures): +// - task 1 (project 1): owned by user1 → write. Has label #4 attached. +// - task 15 (project 6): shared via team 2 read-only → no write. +// - task 16 (project 7): shared via team 3 with write. +// - task 34 (project 20): private to user13 → no access. +// +// Labels: #1 own; #3 (user2, attached to no visible task) is invisible to user1. +func TestLabelTaskBulk_V2(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + put := func(taskID, body string) (*v2ProblemJSON, []int64, int) { + t.Helper() + rec := humaRequest(t, e, http.MethodPut, "/api/v2/tasks/"+taskID+"/labels/bulk", body, token, "") + if rec.Code >= 400 { + var p v2ProblemJSON + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &p), "error body: %s", rec.Body.String()) + return &p, nil, rec.Code + } + var resp struct { + Labels []struct { + ID int64 `json:"id"` + } `json:"labels"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp), "body: %s", rec.Body.String()) + ids := make([]int64, 0, len(resp.Labels)) + for _, l := range resp.Labels { + ids = append(ids, l.ID) + } + return nil, ids, rec.Code + } + + t.Run("Replace adds and removes", func(t *testing.T) { + // task 1 starts with label #4; replacing with [#1] must add #1 and drop #4. + p, ids, code := put("1", `{"labels":[{"id":1}]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids, + "task 1's labels must be exactly {1} after replace") + }) + t.Run("Empty list clears all labels", func(t *testing.T) { + // task 16 (write-shared) gets a label, then an empty replace removes it. + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + + p, ids, code := put("16", `{"labels":[]}`) + require.Nil(t, p) + assert.Equal(t, http.StatusOK, code) + assert.Empty(t, ids, "empty replace must remove every label") + }) + t.Run("Write share can replace", func(t *testing.T) { + _, ids, code := put("16", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusOK, code) + assert.ElementsMatch(t, []int64{1}, ids) + }) + t.Run("Read-only share is forbidden", func(t *testing.T) { + p, _, code := put("15", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Forbidden task", func(t *testing.T) { + // task 34 is private to user13. + p, _, code := put("34", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + }) + t.Run("Nonexisting task", func(t *testing.T) { + p, _, code := put("9999", `{"labels":[{"id":1}]}`) + assert.Equal(t, http.StatusNotFound, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeTaskDoesNotExist, p.Code) + }) + t.Run("Label the user cannot see is rejected", func(t *testing.T) { + // label #3 (user2's, attached to no task user1 can see) is invisible to + // user1; attaching it to a writable task must be refused. + p, _, code := put("1", `{"labels":[{"id":3}]}`) + assert.Equal(t, http.StatusForbidden, code) + require.NotNil(t, p) + assert.Equal(t, models.ErrCodeUserHasNoAccessToLabel, p.Code) + }) +} From 4316554b2747fcd25cee9933c08899d912d2926f Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 22:11:58 +0200 Subject: [PATCH 26/45] docs(api/v2): tag task fields for the v2 schema --- pkg/models/tasks.go | 56 ++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index c11854b93..ee5eda824 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -62,25 +62,25 @@ func validateRepeatAfter(repeatAfter int64) error { // Task represents a task in a project type Task struct { // The unique, numeric id of this task. - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"projecttask" readOnly:"true" doc:"The unique, numeric id of this task."` // The task text. This is what you'll see in the project. - Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1"` + Title string `xorm:"TEXT not null" json:"title" valid:"minstringlength(1)" minLength:"1" doc:"The task title. This is what you'll see in the project."` // The task description. Description string `xorm:"longtext null" json:"description"` // Whether a task is done or not. Done bool `xorm:"INDEX null" json:"done"` // The time when a task was marked as done. This field is system-controlled and cannot be set via API. - DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at"` + DoneAt time.Time `xorm:"INDEX null 'done_at'" json:"done_at" readOnly:"true" doc:"When the task was marked as done. Set by the server; ignored on write."` // The time when the task is due. DueDate time.Time `xorm:"DATETIME INDEX null 'due_date'" json:"due_date"` // An array of reminders that are associated with this task. Reminders []*TaskReminder `xorm:"-" json:"reminders"` // The project this task belongs to. - ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project"` + ProjectID int64 `xorm:"bigint INDEX not null unique(tasks_project_index)" json:"project_id" param:"project" doc:"The id of the project this task belongs to. On create it is taken from the URL; on update, setting it to a different project moves the task (requires write access to the target project)."` // An amount in seconds this task repeats itself. If this is set, when marking the task as done, it will mark itself as "undone" and then increase all remindes and the due date by its amount. - RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)"` + RepeatAfter int64 `xorm:"bigint INDEX null" json:"repeat_after" valid:"range(0|9223372036854775807)" doc:"The interval in seconds this task repeats. When set, marking the task done re-opens it and bumps its reminders and due date by this amount."` // Can have three possible values which will trigger when the task is marked as done: 0 = repeats after the amount specified in repeat_after, 1 = repeats all dates each months (ignoring repeat_after), 3 = repeats from the current date rather than the last set date. - RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode"` + RepeatMode TaskRepeatMode `xorm:"not null default 0" json:"repeat_mode" doc:"How the task repeats when marked done: 0 = after repeat_after seconds, 1 = monthly (ignores repeat_after), 2 = from the current date rather than the last set date."` // The task priority. Can be anything you want, it is possible to sort by this later. Priority int64 `xorm:"bigint null" json:"priority"` // When this task starts. @@ -88,60 +88,60 @@ type Task struct { // When this task ends. EndDate time.Time `xorm:"DATETIME INDEX null 'end_date'" json:"end_date" query:"-"` // An array of users who are assigned to this task - Assignees []*user.User `xorm:"-" json:"assignees"` + Assignees []*user.User `xorm:"-" json:"assignees" readOnly:"true" doc:"The users assigned to this task. Read-only here; use the task-assignee endpoints to change assignments."` // An array of labels which are associated with this task. This property is read-only, you must use the separate endpoint to add labels to a task. - Labels []*Label `xorm:"-" json:"labels"` + Labels []*Label `xorm:"-" json:"labels" readOnly:"true" doc:"The labels on this task. Read-only here; use the label-task endpoints to add or remove labels."` // The task color in hex - HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7"` + HexColor string `xorm:"varchar(6) null" json:"hex_color" valid:"runelength(0|7)" maxLength:"7" doc:"The task color as a hex string without the leading '#'."` // Determines how far a task is left from being done - PercentDone float64 `xorm:"DOUBLE null" json:"percent_done"` + PercentDone float64 `xorm:"DOUBLE null" json:"percent_done" doc:"How far the task is from done, between 0 and 1."` // The task identifier, based on the project identifier and the task's index - Identifier string `xorm:"-" json:"identifier"` + Identifier string `xorm:"-" json:"identifier" readOnly:"true" doc:"The textual task identifier, derived from the project identifier and the task index (e.g. \"PROJ-12\")."` // The task index, calculated per project - Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index"` + Index int64 `xorm:"bigint not null default 0 unique(tasks_project_index)" json:"index" param:"index" readOnly:"true" doc:"The per-project task index, assigned by the server."` // The UID is currently not used for anything other than CalDAV, which is why we don't expose it over json UID string `xorm:"varchar(250) null" json:"-"` // All related tasks, grouped by their relation kind - RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks"` + RelatedTasks RelatedTaskMap `xorm:"-" json:"related_tasks" readOnly:"true" doc:"Related tasks grouped by relation kind. Read-only here; use the task-relation endpoints to change relations."` // All attachments this task has. This property is read-onlym, you must use the separate endpoint to add attachments to a task. - Attachments []*TaskAttachment `xorm:"-" json:"attachments"` + Attachments []*TaskAttachment `xorm:"-" json:"attachments" readOnly:"true" doc:"The task's attachments. Read-only here; use the attachment endpoints to add or remove them."` // If this task has a cover image, the field will return the id of the attachment that is the cover image. - CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id"` + CoverImageAttachmentID int64 `xorm:"bigint default 0" json:"cover_image_attachment_id" doc:"The id of the attachment used as this task's cover image, or 0 for none."` // True if a task is a favorite task. Favorite tasks show up in a separate "Important" project. This value depends on the user making the call to the api. - IsFavorite bool `xorm:"-" json:"is_favorite"` + IsFavorite bool `xorm:"-" json:"is_favorite" doc:"Whether the requesting user has favorited this task. Per-user, so it differs between callers."` - IsUnread *bool `xorm:"-" json:"is_unread,omitempty"` + IsUnread *bool `xorm:"-" json:"is_unread,omitempty" readOnly:"true" doc:"Whether the task is unread for the requesting user. Only present when requested via the is_unread expand option."` // The subscription status for the user reading this task. You can only read this property, use the subscription endpoints to modify it. // Will only returned when retrieving one task. - Subscription *Subscription `xorm:"-" json:"subscription,omitempty"` + Subscription *Subscription `xorm:"-" json:"subscription,omitempty" readOnly:"true" doc:"The requesting user's subscription to this task. Read-only here; use the subscription endpoints to change it. Only present when reading a single task."` // A timestamp when this task was created. You cannot change this value. - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"When this task was created. Set by the server; ignored on write."` // A timestamp when this task was last updated. You cannot change this value. - Updated time.Time `xorm:"updated not null" json:"updated"` + Updated time.Time `xorm:"updated not null" json:"updated" readOnly:"true" doc:"When this task was last updated. Set by the server; ignored on write."` // The bucket id. Will only be populated when the task is accessed via a view with buckets. // Can be used to move a task between buckets. In that case, the new bucket must be in the same view as the old one. - BucketID int64 `xorm:"-" json:"bucket_id"` + BucketID int64 `xorm:"-" json:"bucket_id" doc:"The bucket the task is in. Only populated when the task is accessed via a view with buckets. To move a task between buckets, the new bucket must be in the same view as the old one."` // All buckets across all views this task is part of. Only present when fetching tasks with the `expand` parameter set to `buckets`. - Buckets []*Bucket `xorm:"-" json:"buckets,omitempty"` + Buckets []*Bucket `xorm:"-" json:"buckets,omitempty" readOnly:"true" doc:"The task's buckets across all views. Only present when requested via the buckets expand option."` // All comments of this task. Only present when fetching tasks with the `expand` parameter set to `comments`. - Comments []*TaskComment `xorm:"-" json:"comments,omitempty"` + Comments []*TaskComment `xorm:"-" json:"comments,omitempty" readOnly:"true" doc:"The task's first 50 comments. Only present when requested via the comments expand option."` // Comment count of this task. Only present when fetching tasks with the `expand` parameter set to `comment_count`. - CommentCount *int64 `xorm:"-" json:"comment_count,omitempty"` + CommentCount *int64 `xorm:"-" json:"comment_count,omitempty" readOnly:"true" doc:"The number of comments on this task. Only present when requested via the comment_count expand option."` // Time entry count of this task. Only present when fetching tasks with the `expand` parameter set to `time_entries_count`. - TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty"` + TimeEntriesCount *int64 `xorm:"-" json:"time_entries_count,omitempty" readOnly:"true" doc:"The number of time entries on this task. Only present when requested via the time_entries_count expand option."` // Behaves exactly the same as with the TaskCollection.Expand parameter Expand []TaskCollectionExpandable `xorm:"-" json:"-" query:"expand"` @@ -150,13 +150,13 @@ type Task struct { // When accessing tasks via views with buckets, this is primarily used to sort them based on a range. // Positions are always saved per view. They will automatically be set if you request the tasks through a view // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. - Position float64 `xorm:"-" json:"position"` + Position float64 `xorm:"-" json:"position" readOnly:"true" doc:"The task's position, saved per view. Only non-zero when the task is fetched through a view endpoint; use the task-position endpoint to change it."` // Reactions on that task. - Reactions ReactionMap `xorm:"-" json:"reactions"` + Reactions ReactionMap `xorm:"-" json:"reactions" readOnly:"true" doc:"Reactions on this task. Only present when requested via the reactions expand option."` // The user who initially created the task. - CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-"` + CreatedBy *user.User `xorm:"-" json:"created_by" valid:"-" readOnly:"true" doc:"The user who created this task. Set by the server."` CreatedByID int64 `xorm:"bigint not null" json:"-"` // ID of the user who put that task on the project web.CRUDable `xorm:"-" json:"-"` From 0a879e56a8842ddfbfaa63f37804156aaf4b92b9 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 22:11:58 +0200 Subject: [PATCH 27/45] feat(api/v2): add task CRUD on /api/v2 --- pkg/routes/api/v2/tasks.go | 230 +++++++++++++++++++++++++++ pkg/webtests/huma_task_test.go | 280 +++++++++++++++++++++++++++++++++ 2 files changed, 510 insertions(+) create mode 100644 pkg/routes/api/v2/tasks.go create mode 100644 pkg/webtests/huma_task_test.go diff --git a/pkg/routes/api/v2/tasks.go b/pkg/routes/api/v2/tasks.go new file mode 100644 index 000000000..0c0f2d54c --- /dev/null +++ b/pkg/routes/api/v2/tasks.go @@ -0,0 +1,230 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "strconv" + "strings" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" + "github.com/danielgtaylor/huma/v2/conditional" +) + +// expandDoc lists the accepted expand values; shared between the by-id and +// by-index operations so the docs stay in sync. +const expandDoc = "Embed extra, more expensive data in each task. Repeatable. One of: subtasks, buckets, reactions, comments, comment_count, time_entries_count, is_unread. Expanding can return more tasks than the page limit (subtasks) and inflate the response." + +// parseTaskExpand turns the raw `expand` query values into validated +// TaskCollectionExpandable entries. Kept package-level for the TaskCollection +// list endpoint, which accepts the same option. An invalid value returns the +// model's own validation error, which translateDomainError maps to 422. +func parseTaskExpand(raw []string) ([]models.TaskCollectionExpandable, error) { + if len(raw) == 0 { + return nil, nil + } + expand := make([]models.TaskCollectionExpandable, 0, len(raw)) + for _, e := range raw { + v := models.TaskCollectionExpandable(e) + if err := v.Validate(); err != nil { + return nil, err + } + expand = append(expand, v) + } + return expand, nil +} + +// RegisterTaskRoutes wires Task CRUD onto the Huma API. The list lives on +// TaskCollection, not here. +func RegisterTaskRoutes(api huma.API) { + tags := []string{"tasks"} + + Register(api, huma.Operation{ + OperationID: "tasks-read", + Summary: "Get a task", + Description: "Returns a single task by its numeric id. Sends an ETag; pass it as If-None-Match on a later read to get a 304 Not Modified. " + expandDoc, + Method: "GET", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksRead) + + Register(api, huma.Operation{ + OperationID: "tasks-read-by-index", + Summary: "Get a task by its project index", + Description: "Returns a single task addressed by its per-project index. The {project} segment accepts either a numeric project id or a textual project identifier (e.g. \"PROJ\"); a value made solely of digits is always treated as an id. " + expandDoc, + Method: "GET", + Path: "/projects/{project}/tasks/by-index/{index}", + Tags: tags, + }, tasksReadByIndex) + + Register(api, huma.Operation{ + OperationID: "tasks-create", + Summary: "Create a task", + Description: "Creates a task in the project from the URL. The authenticated user needs write access to that project and becomes the task's creator.", + Method: "POST", + Path: "/projects/{project}/tasks", + Tags: tags, + }, tasksCreate) + + Register(api, huma.Operation{ + OperationID: "tasks-update", + Summary: "Update a task", + Description: "Replaces all of a task's fields; requires write access. Setting project_id to a different project moves the task and also requires write access to the target project. Use PATCH for a partial update.", + Method: "PUT", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksUpdate) + + Register(api, huma.Operation{ + OperationID: "tasks-delete", + Summary: "Delete a task", + Description: "Deletes a task. Requires write access to its project.", + Method: "DELETE", + Path: "/tasks/{projecttask}", + Tags: tags, + }, tasksDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskRoutes) } + +type taskReadOneBody struct { + models.Task + MaxPermission models.Permission `json:"max_permission" readOnly:"true" doc:"The maximum permission the requesting user has on this task (0=read, 1=read/write, 2=admin)."` +} + +func tasksRead(ctx context.Context, in *struct { + ID int64 `path:"projecttask" doc:"The numeric id of the task."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + conditional.Params +}) (*singleReadBody[taskReadOneBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + expand, err := parseTaskExpand(in.Expand) + if err != nil { + return nil, translateDomainError(err) + } + task := &models.Task{ID: in.ID, Expand: expand} + maxPermission, err := handler.DoReadOne(ctx, task, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) +} + +func tasksReadByIndex(ctx context.Context, in *struct { + Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."` + Index int64 `path:"index" doc:"The per-project task index."` + Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + conditional.Params +}) (*singleReadBody[taskReadOneBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + expand, err := parseTaskExpand(in.Expand) + if err != nil { + return nil, translateDomainError(err) + } + projectID, err := resolveProjectIdentifier(in.Project) + if err != nil { + return nil, err + } + + // ID 0 + ProjectID + Index makes the model resolve the id from the + // (project, index) pair in both CanRead and ReadOne. + task := &models.Task{ProjectID: projectID, Index: in.Index, Expand: expand} + maxPermission, err := handler.DoReadOne(ctx, task, a) + if err != nil { + return nil, translateDomainError(err) + } + body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) +} + +func tasksCreate(ctx context.Context, in *struct { + Project int64 `path:"project" doc:"The numeric id of the project to create the task in."` + Body models.Task +}) (*singleBody[models.Task], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + task := &in.Body + task.ProjectID = in.Project // URL wins over body + if err := handler.DoCreate(ctx, task, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Task]{Body: task}, nil +} + +// Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. +func tasksUpdate(ctx context.Context, in *struct { + ID int64 `path:"projecttask"` + Body taskReadOneBody +}) (*singleBody[models.Task], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + task := &in.Body.Task + task.ID = in.ID // URL wins over body + if err := handler.DoUpdate(ctx, task, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Task]{Body: task}, nil +} + +func tasksDelete(ctx context.Context, in *struct { + ID int64 `path:"projecttask"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.Task{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +// resolveProjectIdentifier turns the {project} path segment into a numeric +// project id. A pure-digit value is always an id (mirroring v1's +// ResolveProjectIdentifier middleware); anything else is looked up as a +// case-insensitive identifier and 404s if unknown. +func resolveProjectIdentifier(raw string) (int64, error) { + if id, err := strconv.ParseInt(raw, 10, 64); err == nil { + return id, nil + } + s := db.NewSession() + defer s.Close() + project := &models.Project{} + has, err := s.Where("identifier = ?", strings.ToUpper(raw)).Get(project) + if err != nil { + return 0, translateDomainError(err) + } + if !has { + return 0, huma.Error404NotFound("Project not found") + } + return project.ID, nil +} diff --git a/pkg/webtests/huma_task_test.go b/pkg/webtests/huma_task_test.go new file mode 100644 index 000000000..17583822e --- /dev/null +++ b/pkg/webtests/huma_task_test.go @@ -0,0 +1,280 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaTask mirrors v1's TestTask so v2 contract parity is readable +// side-by-side. Read/update/delete address a task by its numeric id; create +// and by-index live on project-scoped paths that don't fit the harness's +// basePath/{id} shape, so those use humaRequest against a shared env. +// +// Fixture topology the matrix relies on (pkg/db/fixtures/tasks.yml + +// project shares): +// - #1: user1's own task in project 1 (admin) — readable/updatable/deletable. +// - #14: project shared read-only via team — forbidden to write/delete. +// - #34: project 20, private to user13 — invisible to user1. +// - project 6: shared read-only; project 7/8: shared write/admin via team. +func TestHumaTask(t *testing.T) { + testHandler := webHandlerTestV2{ + user: &testuser1, + basePath: "/api/v2/tasks", + idParam: "projecttask", + t: t, + } + + t.Run("ReadOne", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "1"}) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"title":"task #1"`) + assert.Contains(t, rec.Body.String(), `"max_permission":2`) // owner = admin + assert.NotEmpty(t, rec.Result().Header.Get("ETag")) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "99999"}) + require.Error(t, err) + // CanRead resolves the task before the project check, so a missing + // task surfaces as 404, not the 403 the label read uses. + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - private project", func(t *testing.T) { + // Task #34 lives in project 20, private to user13. + _, err := testHandler.testReadOneWithUser(nil, map[string]string{"projecttask": "34"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) + + // The v2 harness loads fixtures once and reuses the env across subtests, + // so each mutating subtest targets a distinct task to stay order-independent + // (unlike v1's webHandlerTest, which reloads fixtures per request). + t.Run("Update", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "3"}, `{"title":"Lorem Ipsum"}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + assert.NotContains(t, rec.Body.String(), `"title":"task #3 high prio"`) + }) + t.Run("Move to another project", func(t *testing.T) { + rec, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "4"}, `{"project_id":7}`) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `"project_id":7`) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "99999"}, `{"title":"x"}`) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "14"}, `{"title":"x"}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - move into a project the user can't write", func(t *testing.T) { + _, err := testHandler.testUpdateWithUser(nil, map[string]string{"projecttask": "5"}, `{"project_id":20}`) + require.Error(t, err) + assertHandlerErrorCode(t, err, models.ErrorCodeGenericForbidden) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "2"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "99999"}) + require.Error(t, err) + assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err)) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + _, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "14"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Shared via team write", func(t *testing.T) { + rec, err := testHandler.testDeleteWithUser(nil, map[string]string{"projecttask": "16"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + }) + }) +} + +// TestHumaTask_Create covers the project-scoped create path, which the harness +// basePath shape can't express. Mirrors v1's TestTask/Create matrix. +func TestHumaTask_Create(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + + create := func(project, body string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodPost, "/api/v2/projects/"+project+"/tasks", body, humaTokenFor(t, &testuser1), "") + } + + t.Run("Normal", func(t *testing.T) { + rec := create("1", `{"title":"Lorem Ipsum"}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + }) + t.Run("Project id from body is ignored - URL wins", func(t *testing.T) { + rec := create("1", `{"title":"url wins","project_id":7}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"project_id":1`) + assert.NotContains(t, rec.Body.String(), `"project_id":7`) + }) + t.Run("Nonexisting project", func(t *testing.T) { + rec := create("9999", `{"title":"x"}`) + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist)) + }) + t.Run("Forbidden - private project", func(t *testing.T) { + rec := create("20", `{"title":"x"}`) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Forbidden - read-only share", func(t *testing.T) { + rec := create("6", `{"title":"x"}`) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Shared via team write", func(t *testing.T) { + rec := create("7", `{"title":"Lorem Ipsum"}`) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"title":"Lorem Ipsum"`) + }) + t.Run("Empty title is rejected", func(t *testing.T) { + rec := create("1", `{"title":""}`) + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_ReadByIndex covers the by-index route, including the textual +// project-identifier resolution that v1 does in echo middleware. Mirrors v1's +// TestTaskByProjectIndex and TestTask/ReadOneByIndex. +func TestHumaTask_ReadByIndex(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + + get := func(project, index string) *httptest.ResponseRecorder { + return humaRequest(t, h.e, http.MethodGet, + fmt.Sprintf("/api/v2/projects/%s/tasks/by-index/%s", project, index), "", humaTokenFor(t, &testuser1), "") + } + + t.Run("By numeric project id", func(t *testing.T) { + rec := get("1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + assert.Contains(t, rec.Body.String(), `"index":1`) + }) + t.Run("By textual project identifier", func(t *testing.T) { + // Project 1 has identifier "TEST1". + rec := get("TEST1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + t.Run("Identifier match is case-insensitive", func(t *testing.T) { + rec := get("test1", "1") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"id":1`) + }) + t.Run("Unknown identifier returns 404", func(t *testing.T) { + rec := get("does-not-exist", "1") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Nonexistent index returns 404", func(t *testing.T) { + rec := get("1", "99999") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("No permission returns 403", func(t *testing.T) { + // Project 2 is inaccessible to user1; must be 403, not a 404 oracle. + rec := get("2", "1") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_Expand asserts the expand query param populates the extra, +// more expensive fields, is repeatable (explode), and rejects unknown values. +// comment_count and reactions are genuinely gated on the flag, so they prove +// the param is wired through; subtasks-as-related-tasks load regardless. +func TestHumaTask_Expand(t *testing.T) { + h := webHandlerTestV2{user: &testuser1, t: t} + require.NoError(t, h.ensureEnv()) + tok := humaTokenFor(t, &testuser1) + + t.Run("absent leaves expand-gated fields empty", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.NotContains(t, rec.Body.String(), `"comment_count":`) + assert.NotContains(t, rec.Body.String(), `"reactions":{`) + }) + t.Run("comment_count", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"comment_count":`, "comment_count must be present: %s", rec.Body.String()) + }) + t.Run("reactions", func(t *testing.T) { + // Task #1 has reaction fixture #1. + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=reactions", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"reactions":{`, "reactions must be embedded: %s", rec.Body.String()) + }) + t.Run("repeated param applies every value", func(t *testing.T) { + // explode binding: both ?expand= values take effect, not just the first. + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=comment_count&expand=reactions", "", tok, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"comment_count":`) + assert.Contains(t, rec.Body.String(), `"reactions":{`) + }) + t.Run("invalid value is rejected", func(t *testing.T) { + rec := humaRequest(t, h.e, http.MethodGet, "/api/v2/tasks/1?expand=bogus", "", tok, "") + // enum on the query param makes Huma reject it as a 422 before the handler. + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestHumaTask_ETagReturns304 covers the v2-only conditional-read behaviour. +func TestHumaTask_ETagReturns304(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + etag := rec.Header().Get("ETag") + require.NotEmpty(t, etag) + + req := httptest.NewRequest(http.MethodGet, "/api/v2/tasks/1", strings.NewReader("")) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("If-None-Match", etag) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusNotModified, rec.Code, "body: %s", rec.Body.String()) +} From 5cdc785b49f9a8507206343239f590f9d4116101 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 09:30:42 +0200 Subject: [PATCH 28/45] fix(api/v2): return ErrProjectDoesNotExist for unknown project identifiers --- pkg/models/project.go | 14 ++++++++++++++ pkg/routes/api/v2/tasks.go | 7 +------ pkg/webtests/huma_task_test.go | 3 ++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/pkg/models/project.go b/pkg/models/project.go index 022f673a5..23fc9f6ca 100644 --- a/pkg/models/project.go +++ b/pkg/models/project.go @@ -448,6 +448,20 @@ func GetProjectSimpleByID(s *xorm.Session, projectID int64) (project *Project, e return } +// GetProjectSimpleByIdentifier gets a project by its textual identifier (e.g. "PROJ"). +// Identifiers are stored uppercase, so the lookup normalizes the input. +func GetProjectSimpleByIdentifier(s *xorm.Session, identifier string) (project *Project, err error) { + project, exists, err := getProjectSimple(s, builder.Eq{"identifier": strings.ToUpper(identifier)}) + if err != nil { + return nil, err + } + if !exists { + return nil, ErrProjectDoesNotExist{} + } + + return +} + func getProjectSimple(s *xorm.Session, cond builder.Cond) (project *Project, exists bool, err error) { project = &Project{} exists, err = s. diff --git a/pkg/routes/api/v2/tasks.go b/pkg/routes/api/v2/tasks.go index 0c0f2d54c..49fa21910 100644 --- a/pkg/routes/api/v2/tasks.go +++ b/pkg/routes/api/v2/tasks.go @@ -19,7 +19,6 @@ package apiv2 import ( "context" "strconv" - "strings" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" @@ -218,13 +217,9 @@ func resolveProjectIdentifier(raw string) (int64, error) { } s := db.NewSession() defer s.Close() - project := &models.Project{} - has, err := s.Where("identifier = ?", strings.ToUpper(raw)).Get(project) + project, err := models.GetProjectSimpleByIdentifier(s, raw) if err != nil { return 0, translateDomainError(err) } - if !has { - return 0, huma.Error404NotFound("Project not found") - } return project.ID, nil } diff --git a/pkg/webtests/huma_task_test.go b/pkg/webtests/huma_task_test.go index 17583822e..8c919e382 100644 --- a/pkg/webtests/huma_task_test.go +++ b/pkg/webtests/huma_task_test.go @@ -204,9 +204,10 @@ func TestHumaTask_ReadByIndex(t *testing.T) { require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) assert.Contains(t, rec.Body.String(), `"id":1`) }) - t.Run("Unknown identifier returns 404", func(t *testing.T) { + t.Run("Unknown identifier returns ErrProjectDoesNotExist", func(t *testing.T) { rec := get("does-not-exist", "1") assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), fmt.Sprintf(`"code":%d`, models.ErrCodeProjectDoesNotExist)) }) t.Run("Nonexistent index returns 404", func(t *testing.T) { rec := get("1", "99999") From cec74717fc6f4931403698486310df922bdf78fb Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 10:27:34 +0200 Subject: [PATCH 29/45] refactor(task-attachment): share upload+download via pkg/web/files for v1+v2 --- .gitignore | 1 + pkg/models/task_attachment.go | 69 +++++++++++++ pkg/routes/api/v1/task_attachment.go | 133 ++++---------------------- pkg/web/files/task_attachment.go | 104 ++++++++++++++++++++ pkg/web/files/task_attachment_test.go | 56 +++++++++++ 5 files changed, 246 insertions(+), 117 deletions(-) create mode 100644 pkg/web/files/task_attachment.go create mode 100644 pkg/web/files/task_attachment_test.go diff --git a/.gitignore b/.gitignore index 25d23cac0..469ce5eb9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ docs/resources/ pkg/static/templates_vfsdata.go files/ !pkg/files/ +!pkg/web/files/ vikunja-dump* vendor/ os-packages/ diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index 59d8d7594..846cd0903 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -23,6 +23,7 @@ import ( "image/png" "io" "strconv" + "strings" "time" "code.vikunja.io/api/pkg/events" @@ -106,6 +107,74 @@ func (ta *TaskAttachment) NewAttachment(s *xorm.Session, f io.ReadSeeker, realna return nil } +// AttachmentToUpload is a transport-neutral file to attach, so the upload logic +// can be shared by the multipart v1 handler and the Huma v2 handler. +type AttachmentToUpload struct { + Reader io.ReadSeeker + Filename string + Size uint64 +} + +// UploadTaskAttachments checks create access to the task, then stores each file, +// collecting per-file failures rather than aborting. The caller owns the session +// and the commit. A returned err means the request as a whole failed (e.g. +// forbidden); per-file failures come back in failures instead. +func UploadTaskAttachments(s *xorm.Session, a web.Auth, taskID int64, uploads []*AttachmentToUpload) (success []*TaskAttachment, failures []error, err error) { + ta := &TaskAttachment{TaskID: taskID} + can, err := ta.CanCreate(s, a) + if err != nil { + return nil, nil, err + } + if !can { + return nil, nil, ErrGenericForbidden{} + } + + for _, upload := range uploads { + attachment := &TaskAttachment{TaskID: taskID} + if err := attachment.NewAttachment(s, upload.Reader, upload.Filename, upload.Size, a); err != nil { + failures = append(failures, err) + continue + } + success = append(success, attachment) + } + return success, failures, nil +} + +// LoadTaskAttachmentForDownload checks read access, loads the attachment with its +// open file, and resolves a preview if previewSize is set and the file is an image. +// It returns the loaded attachment and, when applicable, the preview bytes (the +// caller serves those instead of the file). The caller owns the session, the +// commit, and writing the response. Returns ErrGenericForbidden on denied access. +func LoadTaskAttachmentForDownload(s *xorm.Session, a web.Auth, taskID, attachmentID int64, previewSize PreviewSize) (ta *TaskAttachment, preview []byte, err error) { + ta = &TaskAttachment{ID: attachmentID, TaskID: taskID} + can, _, err := ta.CanRead(s, a) + if err != nil { + return nil, nil, err + } + if !can { + return nil, nil, ErrGenericForbidden{} + } + + if err := ta.ReadOne(s, a); err != nil { + return nil, nil, err + } + if err := ta.File.LoadFileByID(); err != nil { + return nil, nil, err + } + + if previewSize != PreviewSizeUnknown && strings.HasPrefix(ta.File.Mime, "image") { + preview = ta.GetPreview(previewSize) + // GetPreview consumes the file reader; re-open it for the non-preview fallback. + if preview == nil { + if err := ta.File.LoadFileByID(); err != nil { + return nil, nil, err + } + } + } + + return ta, preview, nil +} + // ReadOne returns a task attachment func (ta *TaskAttachment) ReadOne(s *xorm.Session, _ web.Auth) (err error) { query := s.Where("id = ?", ta.ID).NoAutoCondition() diff --git a/pkg/routes/api/v1/task_attachment.go b/pkg/routes/api/v1/task_attachment.go index dd6478703..68197a3bf 100644 --- a/pkg/routes/api/v1/task_attachment.go +++ b/pkg/routes/api/v1/task_attachment.go @@ -18,43 +18,16 @@ package v1 import ( "errors" - "io" - "mime" "net/http" - "strconv" - "strings" - "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" auth2 "code.vikunja.io/api/pkg/modules/auth" - "code.vikunja.io/api/pkg/web" + webfiles "code.vikunja.io/api/pkg/web/files" "github.com/labstack/echo/v5" ) -// attachmentUploadError represents a structured error for attachment upload failures -type attachmentUploadError struct { - Code int `json:"code,omitempty"` - Message string `json:"message"` -} - -// toAttachmentUploadError converts an error to a structured attachmentUploadError -func toAttachmentUploadError(err error) attachmentUploadError { - // Try to get structured error info from HTTPErrorProcessor - if httpErr, ok := err.(web.HTTPErrorProcessor); ok { - errDetails := httpErr.HTTPError() - return attachmentUploadError{ - Code: errDetails.Code, - Message: errDetails.Message, - } - } - // Fall back to just the error message - return attachmentUploadError{ - Message: err.Error(), - } -} - // UploadTaskAttachment handles everything needed for the upload of a task attachment // @Summary Upload a task attachment // @Description Upload a task attachment. You can pass multiple files with the files form param. @@ -76,7 +49,6 @@ func UploadTaskAttachment(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err) } - // Permissions check auth, err := auth2.GetAuthFromClaims(c) if err != nil { return err @@ -85,15 +57,6 @@ func UploadTaskAttachment(c *echo.Context) error { s := db.NewSession() defer s.Close() - can, err := taskAttachment.CanCreate(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !can { - return echo.ErrForbidden - } - // Multipart form form, err := c.MultipartForm() if err != nil { @@ -104,31 +67,23 @@ func UploadTaskAttachment(c *echo.Context) error { return err } - type result struct { - Errors []attachmentUploadError `json:"errors"` - Success []*models.TaskAttachment `json:"success"` - } - r := &result{} fileHeaders := form.File["files"] + uploads := make([]*models.AttachmentToUpload, 0, len(fileHeaders)) + var openErrors []error for _, file := range fileHeaders { - // We create a new attachment object here to have a clean start - ta := &models.TaskAttachment{ - TaskID: taskAttachment.TaskID, - } - f, err := file.Open() if err != nil { - r.Errors = append(r.Errors, toAttachmentUploadError(err)) + openErrors = append(openErrors, err) continue } defer f.Close() + uploads = append(uploads, &models.AttachmentToUpload{Reader: f, Filename: file.Filename, Size: uint64(file.Size)}) + } - err = ta.NewAttachment(s, f, file.Filename, uint64(file.Size), auth) - if err != nil { - r.Errors = append(r.Errors, toAttachmentUploadError(err)) - continue - } - r.Success = append(r.Success, ta) + success, failures, err := models.UploadTaskAttachments(s, auth, taskAttachment.TaskID, uploads) + if err != nil { + _ = s.Rollback() + return err } if err := s.Commit(); err != nil { @@ -136,7 +91,7 @@ func UploadTaskAttachment(c *echo.Context) error { return err } - return c.JSON(http.StatusOK, r) + return c.JSON(http.StatusOK, webfiles.BuildUploadResult(success, append(openErrors, failures...))) } // GetTaskAttachment returns a task attachment to download for the user @@ -160,7 +115,6 @@ func GetTaskAttachment(c *echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "No task ID provided").Wrap(err) } - // Permissions check auth, err := auth2.GetAuthFromClaims(c) if err != nil { return err @@ -169,36 +123,11 @@ func GetTaskAttachment(c *echo.Context) error { s := db.NewSession() defer s.Close() - can, _, err := taskAttachment.CanRead(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - if !can { - return echo.ErrForbidden - } - - // Get the attachment incl file - err = taskAttachment.ReadOne(s, auth) - if err != nil { - _ = s.Rollback() - return err - } - - // Open the file so its content is available for preview generation and download - err = taskAttachment.File.LoadFileByID() - if err != nil { - _ = s.Rollback() - return err - } - - // If the preview query parameter is set, get the preview (cached or generate) previewSize := models.GetPreviewSizeFromString(c.QueryParam("preview_size")) - if previewSize != models.PreviewSizeUnknown && strings.HasPrefix(taskAttachment.File.Mime, "image") { - previewFileBytes := taskAttachment.GetPreview(previewSize) - if previewFileBytes != nil { - return c.Blob(http.StatusOK, "image/png", previewFileBytes) - } + attachment, preview, err := models.LoadTaskAttachmentForDownload(s, auth, taskAttachment.TaskID, taskAttachment.ID, previewSize) + if err != nil { + _ = s.Rollback() + return err } if err := s.Commit(); err != nil { @@ -206,36 +135,6 @@ func GetTaskAttachment(c *echo.Context) error { return err } - mimeToReturn := taskAttachment.File.Mime - if mimeToReturn == "" { - mimeToReturn = "application/octet-stream" - } - - c.Response().Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{ - "filename": taskAttachment.File.Name, - })) - c.Response().Header().Set("Content-Type", mimeToReturn) - c.Response().Header().Set("Content-Length", strconv.FormatUint(taskAttachment.File.Size, 10)) - c.Response().Header().Set("Last-Modified", taskAttachment.File.Created.UTC().Format(http.TimeFormat)) - // Override the global no-store directive so browsers can cache attachments. - // no-cache allows caching but requires revalidation via If-Modified-Since. - c.Response().Header().Set("Cache-Control", "no-cache") - - if config.FilesType.GetString() == "s3" { - // Check If-Modified-Since and return 304 if the file hasn't changed. - // http.ServeContent handles this automatically for local files. - if ifModSince := c.Request().Header.Get("If-Modified-Since"); ifModSince != "" { - if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !taskAttachment.File.Created.UTC().After(t) { - return c.NoContent(http.StatusNotModified) - } - } - - // s3 files cannot use http.ServeContent as it requires a Seekable file - // so we stream the file content directly to the response - _, err = io.Copy(c.Response(), taskAttachment.File.File) - return err - } - - http.ServeContent(c.Response(), c.Request(), taskAttachment.File.Name, taskAttachment.File.Created, taskAttachment.File.File.(io.ReadSeeker)) + webfiles.WriteAttachmentDownload(c.Response(), c.Request(), attachment, preview) return nil } diff --git a/pkg/web/files/task_attachment.go b/pkg/web/files/task_attachment.go new file mode 100644 index 000000000..3db78e62f --- /dev/null +++ b/pkg/web/files/task_attachment.go @@ -0,0 +1,104 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package files holds the HTTP-layer glue for serving task attachments — +// the upload-result DTOs and the download response writer — shared by the +// v1 and v2 handlers. The domain logic stays in pkg/models; this package +// only translates it to and from the wire. +package files + +import ( + "io" + "mime" + "net/http" + "strconv" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web" +) + +// AttachmentUploadError is a per-file upload failure. +type AttachmentUploadError struct { + Code int `json:"code,omitempty" doc:"Vikunja numeric error code, when the failure carries one."` + Message string `json:"message" doc:"A human-readable description of why this file failed."` +} + +// AttachmentUploadResult is the outcome of an attachment upload: files are +// processed independently, so a per-file failure lands in Errors while the +// rest still succeed. +type AttachmentUploadResult struct { + Errors []AttachmentUploadError `json:"errors" doc:"Per-file failures. A file that fails here does not fail the whole request; the others still upload."` + Success []*models.TaskAttachment `json:"success" doc:"The attachments that were created successfully."` +} + +// BuildUploadResult turns the domain function's plain return values into the +// wire DTO, mapping each failure to its numeric code when it carries one. +func BuildUploadResult(success []*models.TaskAttachment, failures []error) *AttachmentUploadResult { + r := &AttachmentUploadResult{Success: success} + for _, err := range failures { + r.Errors = append(r.Errors, toAttachmentUploadError(err)) + } + return r +} + +func toAttachmentUploadError(err error) AttachmentUploadError { + if httpErr, ok := err.(web.HTTPErrorProcessor); ok { + details := httpErr.HTTPError() + return AttachmentUploadError{Code: details.Code, Message: details.Message} + } + return AttachmentUploadError{Message: err.Error()} +} + +// WriteAttachmentDownload streams the attachment (or its preview) to the response: +// http.ServeContent for seekable local files (Range + If-Modified-Since for free), +// a manual 304 + io.Copy otherwise. It closes the file reader. +func WriteAttachmentDownload(w http.ResponseWriter, r *http.Request, ta *models.TaskAttachment, preview []byte) { + defer func() { _ = ta.File.File.Close() }() + + if preview != nil { + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Content-Length", strconv.Itoa(len(preview))) + _, _ = w.Write(preview) + return + } + + mimeToReturn := ta.File.Mime + if mimeToReturn == "" { + mimeToReturn = "application/octet-stream" + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": ta.File.Name})) + w.Header().Set("Content-Type", mimeToReturn) + w.Header().Set("Content-Length", strconv.FormatUint(ta.File.Size, 10)) + w.Header().Set("Last-Modified", ta.File.Created.UTC().Format(http.TimeFormat)) + // Override the global no-store directive so browsers can cache attachments. + w.Header().Set("Cache-Control", "no-cache") + + // Local files are *os.File (seekable), so ServeContent gives Range + + // If-Modified-Since for free; s3 (and the in-memory test storage) return a + // non-seekable reader, so check If-Modified-Since manually and io.Copy. + if seeker, ok := ta.File.File.(io.ReadSeeker); ok { + http.ServeContent(w, r, ta.File.Name, ta.File.Created, seeker) + return + } + + if ifModSince := r.Header.Get("If-Modified-Since"); ifModSince != "" { + if t, parseErr := http.ParseTime(ifModSince); parseErr == nil && !ta.File.Created.UTC().After(t) { + w.WriteHeader(http.StatusNotModified) + return + } + } + _, _ = io.Copy(w, ta.File.File) +} diff --git a/pkg/web/files/task_attachment_test.go b/pkg/web/files/task_attachment_test.go new file mode 100644 index 000000000..5b282b6b6 --- /dev/null +++ b/pkg/web/files/task_attachment_test.go @@ -0,0 +1,56 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package files + +import ( + "errors" + "testing" + + "code.vikunja.io/api/pkg/models" + + "github.com/stretchr/testify/assert" +) + +func TestBuildUploadResult(t *testing.T) { + t.Run("maps a domain error to its numeric code", func(t *testing.T) { + // ErrTaskAttachmentIsTooLarge is an HTTPErrorProcessor, so its Code must surface. + r := BuildUploadResult(nil, []error{models.ErrTaskAttachmentIsTooLarge{Size: 99}}) + assert.Empty(t, r.Success) + if assert.Len(t, r.Errors, 1) { + assert.Equal(t, models.ErrCodeTaskAttachmentIsTooLarge, r.Errors[0].Code) + assert.NotEmpty(t, r.Errors[0].Message) + } + }) + + t.Run("plain error has no code, just the message", func(t *testing.T) { + r := BuildUploadResult(nil, []error{errors.New("boom")}) + if assert.Len(t, r.Errors, 1) { + assert.Zero(t, r.Errors[0].Code) + assert.Equal(t, "boom", r.Errors[0].Message) + } + }) + + t.Run("preserves success and failure order", func(t *testing.T) { + success := []*models.TaskAttachment{{ID: 1}, {ID: 2}} + r := BuildUploadResult(success, []error{errors.New("first"), errors.New("second")}) + assert.Equal(t, success, r.Success) + if assert.Len(t, r.Errors, 2) { + assert.Equal(t, "first", r.Errors[0].Message) + assert.Equal(t, "second", r.Errors[1].Message) + } + }) +} From dc935f263cca9edb7574ab23e5d7054465aca027 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 10:27:39 +0200 Subject: [PATCH 30/45] docs(api/v2): tag task attachment fields for the v2 schema --- pkg/files/files.go | 10 +++++----- pkg/models/task_attachment.go | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/files/files.go b/pkg/files/files.go index a8d702913..5542a5f5f 100644 --- a/pkg/files/files.go +++ b/pkg/files/files.go @@ -37,12 +37,12 @@ import ( // File holds all information about a file type File struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` - Name string `xorm:"text not null" json:"name"` - Mime string `xorm:"text null" json:"mime"` - Size uint64 `xorm:"bigint not null" json:"size"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this file."` + Name string `xorm:"text not null" json:"name" readOnly:"true" doc:"The original name of the uploaded file."` + Mime string `xorm:"text null" json:"mime" readOnly:"true" doc:"The detected mime type of the file."` + Size uint64 `xorm:"bigint not null" json:"size" readOnly:"true" doc:"The size of the file in bytes."` - Created time.Time `xorm:"created" json:"created"` + Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this file was uploaded."` CreatedByID int64 `xorm:"bigint not null" json:"-"` File io.ReadCloser `xorm:"-" json:"-"` diff --git a/pkg/models/task_attachment.go b/pkg/models/task_attachment.go index 846cd0903..327e2311a 100644 --- a/pkg/models/task_attachment.go +++ b/pkg/models/task_attachment.go @@ -39,16 +39,16 @@ import ( // TaskAttachment is the definition of a task attachment type TaskAttachment struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment"` - TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" param:"attachment" readOnly:"true" doc:"The unique, numeric id of this attachment."` + TaskID int64 `xorm:"bigint not null" json:"task_id" param:"task" readOnly:"true" doc:"The id of the task this attachment belongs to. Taken from the URL, not the body."` FileID int64 `xorm:"bigint not null" json:"-"` CreatedByID int64 `xorm:"bigint not null" json:"-"` - CreatedBy *user.User `xorm:"-" json:"created_by"` + CreatedBy *user.User `xorm:"-" json:"created_by" readOnly:"true" doc:"The user who uploaded this attachment."` - File *files.File `xorm:"-" json:"file"` + File *files.File `xorm:"-" json:"file" readOnly:"true" doc:"Metadata of the uploaded file (name, mime type, size). The bytes are fetched from the download endpoint, not this field."` - Created time.Time `xorm:"created" json:"created"` + Created time.Time `xorm:"created" json:"created" readOnly:"true" doc:"A timestamp when this attachment was uploaded. You cannot change this value."` web.CRUDable `xorm:"-" json:"-"` web.Permissions `xorm:"-" json:"-"` From a1621fec37e9b7a7a50f09146e6b53d9cd91beac Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 10:27:44 +0200 Subject: [PATCH 31/45] feat(api/v2): add task attachments on /api/v2 --- pkg/routes/api/v2/task_attachments.go | 215 ++++++++++++++++++ pkg/webtests/huma_task_attachment_test.go | 258 ++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 pkg/routes/api/v2/task_attachments.go create mode 100644 pkg/webtests/huma_task_attachment_test.go diff --git a/pkg/routes/api/v2/task_attachments.go b/pkg/routes/api/v2/task_attachments.go new file mode 100644 index 000000000..9faee2c0d --- /dev/null +++ b/pkg/routes/api/v2/task_attachments.go @@ -0,0 +1,215 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/humaecho5" + webfiles "code.vikunja.io/api/pkg/web/files" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// models.TaskAttachment.ReadAll returns []*models.TaskAttachment. +type taskAttachmentListBody struct { + Body Paginated[*models.TaskAttachment] +} + +type taskAttachmentUploadInput struct { + TaskID int64 `path:"task" doc:"The id of the task to attach the files to."` + // Accept any upload; the byte-level mime detection happens in files.CreateWithSession, + // so there is no part content-type allow-list to enforce here (unlike the avatar endpoint). + RawBody huma.MultipartFormFiles[struct { + Files []huma.FormFile `form:"files" required:"true" doc:"One or more files to upload as task attachments. Send multiple parts under the same \"files\" field to upload several at once."` + }] +} + +type taskAttachmentUploadBody struct { + Body *webfiles.AttachmentUploadResult +} + +// RegisterTaskAttachmentRoutes wires task-attachment list/upload/download/delete onto +// the Huma API. The whole resource is gated by the service.enabletaskattachments config +// flag; the check runs here (not at init()) because RegisterAll fires after config loads. +func RegisterTaskAttachmentRoutes(api huma.API) { + if !config.ServiceEnableTaskAttachments.GetBool() { + return + } + + tags := []string{"task"} + + Register(api, huma.Operation{ + OperationID: "task-attachments-list", + Summary: "List a task's attachments", + Description: "Returns the attachment metadata for one task, paginated. Requires read access to the task. The file bytes are not included; fetch them from the download endpoint.", + Method: http.MethodGet, + Path: "/tasks/{task}/attachments", + Tags: tags, + }, taskAttachmentsList) + + Register(api, huma.Operation{ + OperationID: "task-attachments-upload", + Summary: "Upload task attachments", + Description: "Uploads one or more files as attachments to a task via multipart/form-data under the \"files\" field. Requires write access to the task. Each file is processed independently: a file that fails (for example, exceeding the configured size limit) is reported in the errors list while the others still succeed, so the request returns 201 even on a partial upload. The max size per file is the server's configured file size limit.", + Method: http.MethodPost, + Path: "/tasks/{task}/attachments", + Tags: tags, + // +2 MB mirrors Echo's global BodyLimit overhead so a max-sized file isn't rejected by multipart boundary/header bytes. + // #nosec G115 - configured value won't exceed int64 max in practice. + MaxBodyBytes: (int64(config.GetMaxFileSizeInMBytes()) + 2) * 1024 * 1024, + }, taskAttachmentsUpload) + + Register(api, huma.Operation{ + OperationID: "task-attachments-download", + Summary: "Download a task attachment", + Description: "Returns the raw bytes of one attachment. Requires read access to the task. Pass preview_size to get a downscaled PNG preview instead — only for image attachments; for non-images or an unknown size the original file is returned. The Content-Type header carries the file's real mime type.", + Method: http.MethodGet, + Path: "/tasks/{task}/attachments/{attachment}", + Tags: tags, + // Spell out the binary response; a bare []byte Body would otherwise be + // modeled as a base64 JSON string instead of binary file data. + Responses: map[string]*huma.Response{ + "200": { + Description: "The attachment file bytes. The Content-Type header carries the file's mime type.", + Content: map[string]*huma.MediaType{ + "application/octet-stream": { + Schema: &huma.Schema{Type: huma.TypeString, Format: "binary"}, + }, + }, + }, + }, + }, taskAttachmentsDownload) + + Register(api, huma.Operation{ + OperationID: "task-attachments-delete", + Summary: "Delete a task attachment", + Description: "Deletes one attachment and its underlying file. Requires write access to the task. The attachment must belong to the task in the path.", + Method: http.MethodDelete, + Path: "/tasks/{task}/attachments/{attachment}", + Tags: tags, + }, taskAttachmentsDelete) +} + +func init() { AddRouteRegistrar(RegisterTaskAttachmentRoutes) } + +func taskAttachmentsList(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task whose attachments to list."` + ListParams +}) (*taskAttachmentListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.TaskAttachment{TaskID: in.TaskID}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.TaskAttachment) + if !ok { + return nil, fmt.Errorf("taskAttachments.ReadAll returned unexpected type %T (expected []*models.TaskAttachment)", result) + } + return &taskAttachmentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} + +// taskAttachmentsUpload owns auth, the session and the permission check because +// there is no handler.Do* for multipart uploads (see the api-v2-routes skill's +// "Non-CRUDable / custom routes" section). +func taskAttachmentsUpload(ctx context.Context, in *taskAttachmentUploadInput) (*taskAttachmentUploadBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + formFiles := in.RawBody.Data().Files + uploads := make([]*models.AttachmentToUpload, 0, len(formFiles)) + for _, file := range formFiles { + uploads = append(uploads, &models.AttachmentToUpload{Reader: file, Filename: file.Filename, Size: uint64(file.Size)}) + } + + success, failures, err := models.UploadTaskAttachments(s, a, in.TaskID, uploads) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &taskAttachmentUploadBody{Body: webfiles.BuildUploadResult(success, failures)}, nil +} + +// taskAttachmentsDownload owns auth, the session and the permission check; there is +// no handler.Do* for a file body. It loads the attachment, then streams the bytes +// from the StreamResponse callback (no buffering — attachments can be large). +func taskAttachmentsDownload(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."` + AttachmentID int64 `path:"attachment" doc:"The id of the attachment to download."` + PreviewSize string `query:"preview_size" enum:"sm,md,lg,xl" doc:"If set and the attachment is an image, return a downscaled PNG preview instead of the original: sm=100px, md=200px, lg=400px, xl=800px. Ignored for non-image attachments."` +}) (*huma.StreamResponse, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + previewSize := models.GetPreviewSizeFromString(in.PreviewSize) + ta, preview, err := models.LoadTaskAttachmentForDownload(s, a, in.TaskID, in.AttachmentID, previewSize) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + // The file reader comes from object storage, not the DB session, so it stays + // valid after the commit; the StreamResponse callback runs after this returns. + if err := s.Commit(); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + return &huma.StreamResponse{Body: func(hctx huma.Context) { + c := humaecho5.Unwrap(hctx) + webfiles.WriteAttachmentDownload((*c).Response(), (*c).Request(), ta, preview) + }}, nil +} + +func taskAttachmentsDelete(ctx context.Context, in *struct { + TaskID int64 `path:"task" doc:"The id of the task the attachment belongs to."` + AttachmentID int64 `path:"attachment" doc:"The id of the attachment to delete."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.TaskAttachment{ID: in.AttachmentID, TaskID: in.TaskID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_task_attachment_test.go b/pkg/webtests/huma_task_attachment_test.go new file mode 100644 index 000000000..74d4ea0ed --- /dev/null +++ b/pkg/webtests/huma_task_attachment_test.go @@ -0,0 +1,258 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "bytes" + "encoding/json" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// multipartFilesBody builds a multipart/form-data body with one or more files +// under the "files" field, matching the v2 upload handler's form schema. +func multipartFilesBody(t *testing.T, files map[string][]byte) (*bytes.Buffer, string) { + t.Helper() + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + for filename, content := range files { + fw, err := w.CreateFormFile("files", filename) + require.NoError(t, err) + _, err = fw.Write(content) + require.NoError(t, err) + } + require.NoError(t, w.Close()) + return buf, w.FormDataContentType() +} + +func uploadAttachmentRequest(t *testing.T, e *echo.Echo, taskID string, body *bytes.Buffer, contentType, token string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v2/tasks/"+taskID+"/attachments", body) + req.Header.Set("Content-Type", contentType) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// uploadOneAttachment uploads a single file to task 1 and returns the created +// attachment id, so download/delete tests have a real file in storage to act on +// (setupTestEnv resets the mem storage, so fixture files have no bytes). +func uploadOneAttachment(t *testing.T, e *echo.Echo, token, filename string, content []byte) int64 { + t.Helper() + body, contentType := multipartFilesBody(t, map[string][]byte{filename: content}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Body struct { + Success []*models.TaskAttachment `json:"success"` + Errors []struct { + Message string `json:"message"` + } `json:"errors"` + } + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp.Body)) + require.Empty(t, resp.Body.Errors, "upload reported per-file errors: %+v", resp.Body.Errors) + require.Len(t, resp.Body.Success, 1) + require.NotZero(t, resp.Body.Success[0].ID) + return resp.Body.Success[0].ID +} + +func TestTaskAttachmentsV2(t *testing.T) { + t.Run("Upload single file", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{"hello.txt": []byte("hello world")}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "hello.txt") + assert.Contains(t, rec.Body.String(), `"success"`) + }) + + t.Run("Upload multiple files", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{ + "one.txt": []byte("first file"), + "two.txt": []byte("second file"), + }) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, token) + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Success []*models.TaskAttachment `json:"success"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Len(t, resp.Success, 2) + }) + + t.Run("List", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Upload first so there is at least one attachment with a real file row. + uploadOneAttachment(t, e, token, "listed.txt", []byte("listed content")) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var resp struct { + Items []*models.TaskAttachment `json:"items"` + Total int64 `json:"total"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp.Items) + assert.Positive(t, resp.Total) + }) + + t.Run("Download returns bytes and content type", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + content := []byte("downloadable content") + id := uploadOneAttachment(t, e, token, "download.txt", content) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "the streamed file bytes must match the original") + assert.NotEmpty(t, rec.Header().Get("Content-Type")) + assert.Contains(t, rec.Header().Get("Content-Disposition"), "download.txt") + // Caching headers mirror v1: a concrete length and a cacheable directive. + assert.Equal(t, strconv.Itoa(len(content)), rec.Header().Get("Content-Length")) + assert.Equal(t, "no-cache", rec.Header().Get("Cache-Control")) + assert.NotEmpty(t, rec.Header().Get("Last-Modified")) + }) + + t.Run("Delete", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + id := uploadOneAttachment(t, e, token, "todelete.txt", []byte("bye")) + + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + require.Equal(t, http.StatusNoContent, rec.Code, "body: %s", rec.Body.String()) + + // The download must now 404. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10), "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Upload forbidden on inaccessible task", func(t *testing.T) { + // Task 34 is owned by user 13 and inaccessible to testuser1 (see the v1 IDOR test). + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + body, contentType := multipartFilesBody(t, map[string][]byte{"nope.txt": []byte("nope")}) + rec := uploadAttachmentRequest(t, e, "34", body, contentType, token) + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("List forbidden on inaccessible task", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/34/attachments", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Download nonexistent attachment", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/99999", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Cannot download attachment that does not belong to the task in the path", func(t *testing.T) { + // Mirrors the v1 IDOR test: attachment 4 belongs to task 34, not task 1. + // Requesting it under task 1 (accessible) must 404, not leak the file. + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/4", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Unauthenticated upload is rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + body, contentType := multipartFilesBody(t, map[string][]byte{"x.txt": []byte("x")}) + rec := uploadAttachmentRequest(t, e, "1", body, contentType, "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "body: %s", rec.Body.String()) + }) +} + +// TestTaskAttachmentsV2_PreviewSize covers the preview_size query param: a non-image +// attachment ignores it and returns the original bytes (the v1 behaviour). +func TestTaskAttachmentsV2_PreviewSize(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + content := []byte("not an image, just text") + id := uploadOneAttachment(t, e, token, "notimage.txt", content) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments/"+strconv.FormatInt(id, 10)+"?preview_size=md", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Equal(t, content, rec.Body.Bytes(), "preview_size on a non-image must return the original file") +} + +// TestTaskAttachmentsV2_Disabled proves the resource is absent when the +// service.enabletaskattachments config flag is off. +func TestTaskAttachmentsV2_Disabled(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + oldValue := config.ServiceEnableTaskAttachments.GetBool() + config.ServiceEnableTaskAttachments.Set(false) + defer config.ServiceEnableTaskAttachments.Set(oldValue) + + // Rebuild the router so RegisterAll re-evaluates the (now disabled) flag. + e := routes.NewEcho() + routes.RegisterRoutes(e) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1/attachments", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, + "attachment routes must not be registered when the flag is off; body: %s", rec.Body.String()) +} From a221a15ec3e691b91673a9752279f69520666875 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 32/45] feat(client): add parent_project_id and position to Project wire type The init project picker needs the parent/child relationship and sibling ordering to render projects hierarchically like the web sidebar. --- veans/internal/client/types.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/veans/internal/client/types.go b/veans/internal/client/types.go index e661f5bc2..edcabc766 100644 --- a/veans/internal/client/types.go +++ b/veans/internal/client/types.go @@ -46,11 +46,13 @@ type BotUserCreate struct { // Project mirrors pkg/models/project.Project. type Project struct { - ID int64 `json:"id"` - Title string `json:"title"` - Description string `json:"description,omitempty"` - Identifier string `json:"identifier,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` + ID int64 `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` + Identifier string `json:"identifier,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + ParentProjectID int64 `json:"parent_project_id,omitempty"` + Position float64 `json:"position,omitempty"` } // ProjectView is a saved view (Kanban/List/Gantt/Table) on a project. From 3462e24ec7ba3d0d970d56e9433f50b624ca9ce8 Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 33/45] feat(picker): add hierarchical fuzzy project picker Interactive bubbletea picker that renders projects as an indented tree (siblings by position then title, orphans re-parented to root) and fuzzy-filters as you type, keeping matched rows' ancestors visible as dimmed context. Pure tree/flatten logic is split from the TUI and unit-tested. --- veans/go.mod | 19 ++ veans/go.sum | 45 +++++ veans/internal/picker/flatten.go | 118 +++++++++++++ veans/internal/picker/flatten_test.go | 122 +++++++++++++ veans/internal/picker/model.go | 238 ++++++++++++++++++++++++++ veans/internal/picker/picker.go | 71 ++++++++ veans/internal/picker/tree.go | 78 +++++++++ veans/internal/picker/tree_test.go | 129 ++++++++++++++ 8 files changed, 820 insertions(+) create mode 100644 veans/internal/picker/flatten.go create mode 100644 veans/internal/picker/flatten_test.go create mode 100644 veans/internal/picker/model.go create mode 100644 veans/internal/picker/picker.go create mode 100644 veans/internal/picker/tree.go create mode 100644 veans/internal/picker/tree_test.go diff --git a/veans/go.mod b/veans/go.mod index c025994dc..88e1cfca7 100644 --- a/veans/go.mod +++ b/veans/go.mod @@ -3,9 +3,12 @@ module code.vikunja.io/veans go 1.25.0 require ( + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b github.com/magefile/mage v1.17.2 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c + github.com/sahilm/fuzzy v0.1.2 github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.8 golang.org/x/sys v0.43.0 @@ -14,8 +17,24 @@ require ( ) require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/text v0.3.8 // indirect ) diff --git a/veans/go.sum b/veans/go.sum index 3e0c9d612..5490b412a 100644 --- a/veans/go.sum +++ b/veans/go.sum @@ -1,3 +1,17 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= @@ -5,17 +19,40 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b h1:qZ21OofI7zneC9dOEqul4FmIWz/YjJJMrf6fL7jrFYQ= github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= github.com/magefile/mage v1.17.2/go.mod h1:Yj51kqllmsgFpvvSzgrZPK9WtluG3kUhFaBUVLo4feA= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.2 h1:kdSkz23lx1meNjEl+SLJULeSbjTI4Dn14K/YxdGrIww= +github.com/sahilm/fuzzy v0.1.2/go.mod h1:au6//VbVSqu6DFrkL2CfjlJ5iURpNCPeE+1GwY3XsT8= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= @@ -24,14 +61,22 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/veans/internal/picker/flatten.go b/veans/internal/picker/flatten.go new file mode 100644 index 000000000..bb9561a4a --- /dev/null +++ b/veans/internal/picker/flatten.go @@ -0,0 +1,118 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "unicode/utf8" + + "code.vikunja.io/veans/internal/client" + "github.com/sahilm/fuzzy" +) + +// row is one visible line in the picker. matches holds rune indexes into the +// title for highlighting; dimmed rows are kept only as context for a matching +// descendant and are skipped by the cursor. +type row struct { + project *client.Project + depth int + dimmed bool + matches []int +} + +// flatten walks the forest depth-first into a render list. An empty query +// returns every node undimmed. A non-empty query fuzzy-matches each title +// (case-insensitive, via sahilm/fuzzy) and keeps a node iff it matches or any +// descendant is kept; a kept-but-non-matching node is dimmed context. +func flatten(forest []*node, query string) []row { + if query == "" { + var rows []row + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + rows = append(rows, row{project: n.project, depth: n.depth}) + walk(n.children) + } + } + walk(forest) + return rows + } + + var rows []row + var walk func(n *node) bool + walk = func(n *node) bool { + matches, matched := matchTitle(query, n.project.Title) + + start := len(rows) + rows = append(rows, row{}) // placeholder; finalized only if kept + + descendantKept := false + for _, c := range n.children { + if walk(c) { + descendantKept = true + } + } + + if !matched && !descendantKept { + rows = rows[:start] + return false + } + rows[start] = row{ + project: n.project, + depth: n.depth, + dimmed: !matched, + matches: matches, + } + return true + } + + for _, n := range forest { + walk(n) + } + return rows +} + +// matchTitle reports whether title fuzzy-matches query and, if so, the rune +// indexes of the matched characters. sahilm/fuzzy reports byte indexes, so we +// translate them to rune offsets for correct highlighting of multibyte titles. +func matchTitle(query, title string) (runeMatches []int, matched bool) { + results := fuzzy.Find(query, []string{title}) + if len(results) == 0 { + return nil, false + } + return byteToRuneIndexes(title, results[0].MatchedIndexes), true +} + +func byteToRuneIndexes(s string, byteIdx []int) []int { + if len(byteIdx) == 0 { + return nil + } + want := make(map[int]bool, len(byteIdx)) + for _, b := range byteIdx { + want[b] = true + } + out := make([]int, 0, len(byteIdx)) + runePos := 0 + for b := 0; b < len(s); { + if want[b] { + out = append(out, runePos) + } + _, size := utf8.DecodeRuneInString(s[b:]) + b += size + runePos++ + } + return out +} diff --git a/veans/internal/picker/flatten_test.go b/veans/internal/picker/flatten_test.go new file mode 100644 index 000000000..fe60411fb --- /dev/null +++ b/veans/internal/picker/flatten_test.go @@ -0,0 +1,122 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func sampleForest() []*node { + return buildForest([]*client.Project{ + proj(1, 0, 1, "Backend"), + proj(2, 1, 1, "Frontend"), + proj(3, 1, 2, "Database"), + proj(4, 0, 2, "Marketing"), + }) +} + +func rowTitles(rows []row) []string { + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r.project.Title + } + return out +} + +func TestFlatten_EmptyQuery(t *testing.T) { + rows := flatten(sampleForest(), "") + wantTitles := []string{"Backend", "Frontend", "Database", "Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + wantDepths := []int{0, 1, 1, 0} + for i, r := range rows { + if r.depth != wantDepths[i] { + t.Errorf("row %d depth = %d, want %d", i, r.depth, wantDepths[i]) + } + if r.dimmed { + t.Errorf("row %d should not be dimmed on empty query", i) + } + if r.matches != nil { + t.Errorf("row %d should have nil matches on empty query", i) + } + } +} + +func TestFlatten_DeepChildSurfacesDimmedAncestor(t *testing.T) { + // "Frontend" is a child of "Backend"; matching it must keep "Backend" + // as a dimmed context row. + rows := flatten(sampleForest(), "frontend") + wantTitles := []string{"Backend", "Frontend"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } + if !rows[0].dimmed { + t.Error("ancestor Backend should be dimmed (context only)") + } + if rows[1].dimmed { + t.Error("matching Frontend should not be dimmed") + } +} + +func TestFlatten_MatchingNodeCarriesMatchIndexes(t *testing.T) { + rows := flatten(sampleForest(), "front") + var frontend *row + for i := range rows { + if rows[i].project.Title == "Frontend" { + frontend = &rows[i] + } + } + if frontend == nil { + t.Fatal("Frontend row missing") + } + // "front" should match the leading runes of "Frontend". + want := []int{0, 1, 2, 3, 4} + if !reflect.DeepEqual(frontend.matches, want) { + t.Fatalf("matches: got %v, want %v", frontend.matches, want) + } +} + +func TestFlatten_NonMatchingSiblingsDropped(t *testing.T) { + // Matching "Marketing" must not pull in "Backend"/"Frontend"/"Database". + rows := flatten(sampleForest(), "marketing") + wantTitles := []string{"Marketing"} + if got := rowTitles(rows); !reflect.DeepEqual(got, wantTitles) { + t.Fatalf("titles: got %v, want %v", got, wantTitles) + } +} + +func TestFlatten_NoMatchYieldsEmpty(t *testing.T) { + rows := flatten(sampleForest(), "zzzzz") + if len(rows) != 0 { + t.Fatalf("expected no rows, got %v", rowTitles(rows)) + } +} + +func TestFlatten_CaseInsensitive(t *testing.T) { + lower := flatten(sampleForest(), "backend") + upper := flatten(sampleForest(), "BACKEND") + if !reflect.DeepEqual(rowTitles(lower), rowTitles(upper)) { + t.Fatalf("case sensitivity differs: %v vs %v", rowTitles(lower), rowTitles(upper)) + } + if len(lower) == 0 { + t.Fatal("expected at least one match for 'backend'") + } +} diff --git a/veans/internal/picker/model.go b/veans/internal/picker/model.go new file mode 100644 index 000000000..4139d0f14 --- /dev/null +++ b/veans/internal/picker/model.go @@ -0,0 +1,238 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "fmt" + "strings" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const maxVisibleRows = 12 + +var ( + dimStyle = lipgloss.NewStyle().Faint(true) + matchStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) + cursorMark = "❯" +) + +// model is the bubbletea state for the picker. The pinned "create a new +// project" entry is the trailing row with a nil project; it is always +// selectable and never filtered out. +type model struct { + forest []*node + query string + rows []row + cursor int // index into rows, always on a selectable row + offset int // first visible row index + + result *client.Project + createNew bool + canceled bool +} + +func newModel(forest []*node) *model { + m := &model{forest: forest} + m.recompute() + return m +} + +func (m *model) recompute() { + rows := flatten(m.forest, m.query) + rows = append(rows, row{project: nil}) // pinned create row + m.rows = rows + // recompute only runs when the query changes (or on init), so snap to the + // first match. Keeping the old cursor could leave it on the trailing create + // row after the list narrows, making Enter create a project instead of + // picking the visible match. + m.cursor = 0 + m.offset = 0 + m.clampCursor() + m.ensureVisible() +} + +func (r row) isCreate() bool { return r.project == nil } + +func (r row) selectable() bool { return r.isCreate() || !r.dimmed } + +func (m *model) clampCursor() { + if m.cursor >= len(m.rows) { + m.cursor = len(m.rows) - 1 + } + if m.cursor < 0 { + m.cursor = 0 + } + if m.rows[m.cursor].selectable() { + return + } + // Snap to the nearest selectable row, preferring downward. + for i := m.cursor; i < len(m.rows); i++ { + if m.rows[i].selectable() { + m.cursor = i + return + } + } + for i := m.cursor; i >= 0; i-- { + if m.rows[i].selectable() { + m.cursor = i + return + } + } +} + +func (m *model) moveCursor(delta int) { + i := m.cursor + for { + i += delta + if i < 0 || i >= len(m.rows) { + return + } + if m.rows[i].selectable() { + m.cursor = i + m.ensureVisible() + return + } + } +} + +func (m *model) ensureVisible() { + if m.cursor < m.offset { + m.offset = m.cursor + } + if m.cursor >= m.offset+maxVisibleRows { + m.offset = m.cursor - maxVisibleRows + 1 + } + if m.offset < 0 { + m.offset = 0 + } +} + +func (m *model) Init() tea.Cmd { return nil } + +func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + key, ok := msg.(tea.KeyMsg) + if !ok { + return m, nil + } + switch key.String() { + case "ctrl+c", "esc": + m.canceled = true + return m, tea.Quit + case "enter": + sel := m.rows[m.cursor] + if sel.isCreate() { + m.createNew = true + } else { + m.result = sel.project + } + return m, tea.Quit + case "up": + m.moveCursor(-1) + case "down": + m.moveCursor(1) + case "backspace": + if m.query != "" { + r := []rune(m.query) + m.query = string(r[:len(r)-1]) + m.recompute() + } + default: + // Treat printable runes and space as query input. + if key.Type == tea.KeyRunes || key.Type == tea.KeySpace { + runes := key.Runes + // KeySpace is not guaranteed to populate key.Runes; substitute a + // literal space so multi-word fuzzy queries still work. + if key.Type == tea.KeySpace && len(runes) == 0 { + runes = []rune{' '} + } + m.query += string(runes) + m.recompute() + } + } + return m, nil +} + +func (m *model) View() string { + var b strings.Builder + fmt.Fprintf(&b, "> %s\n", m.query) + + end := min(m.offset+maxVisibleRows, len(m.rows)) + for i := m.offset; i < end; i++ { + b.WriteString(m.renderRow(i)) + b.WriteByte('\n') + } + + fmt.Fprintf(&b, "%d/%d ↑↓ move ⏎ pick esc cancel\n", m.cursor+1, len(m.rows)) + return b.String() +} + +func (m *model) renderRow(i int) string { + r := m.rows[i] + + marker := " " + if i == m.cursor { + marker = cursorMark + " " + } + + indent := strings.Repeat(" ", r.depth) + + var label string + switch { + case r.isCreate(): + label = "Create a new project" + case r.dimmed: + label = dimStyle.Render(r.project.Title + projectSuffix(r.project)) + default: + label = highlight(r.project.Title, r.matches) + dimStyle.Render(projectSuffix(r.project)) + } + + return marker + indent + label +} + +// projectSuffix is the dimmed metadata appended to a project row. Titles aren't +// unique in Vikunja, so the id (and identifier when set) keeps duplicate-titled +// projects distinguishable during init. +func projectSuffix(p *client.Project) string { + s := fmt.Sprintf(" #%d", p.ID) + if p.Identifier != "" { + s += " " + p.Identifier + } + return s +} + +// highlight bolds the matched runes of title. matches are rune indexes. +func highlight(title string, matches []int) string { + if len(matches) == 0 { + return title + } + matchSet := make(map[int]bool, len(matches)) + for _, idx := range matches { + matchSet[idx] = true + } + var b strings.Builder + for i, r := range []rune(title) { + if matchSet[i] { + b.WriteString(matchStyle.Render(string(r))) + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/veans/internal/picker/picker.go b/veans/internal/picker/picker.go new file mode 100644 index 000000000..ee373e1a4 --- /dev/null +++ b/veans/internal/picker/picker.go @@ -0,0 +1,71 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "errors" + "fmt" + "os" + + "code.vikunja.io/veans/internal/client" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" +) + +// Result is what the user chose: an existing project or the create-new action. +type Result struct { + Project *client.Project + CreateNew bool +} + +var ( + // ErrCanceled is returned when the user dismisses the picker (Esc / Ctrl-C). + ErrCanceled = errors.New("selection canceled") + // ErrNotATerminal is returned when stdin is not a TTY, so the interactive + // picker can't run — callers should fall back to `--project `. + ErrNotATerminal = errors.New("not a terminal") +) + +// Pick runs the interactive project picker over projects and returns the +// user's choice. Output is written to stderr (prompts go to stderr by +// convention) and the terminal is left in canonical mode on exit. +func Pick(projects []*client.Project) (Result, error) { + // The picker reads stdin and draws to stderr; both must be a TTY, else it + // would run invisibly (e.g. stderr redirected to a file) and look hung. + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return Result{}, ErrNotATerminal + } + + m := newModel(buildForest(projects)) + prog := tea.NewProgram(m, tea.WithInput(os.Stdin), tea.WithOutput(os.Stderr)) + final, err := prog.Run() + if err != nil { + return Result{}, fmt.Errorf("run project picker: %w", err) + } + + fm, ok := final.(*model) + if !ok { + return Result{}, fmt.Errorf("project picker returned unexpected model type %T", final) + } + if fm.canceled { + return Result{}, ErrCanceled + } + if fm.createNew { + return Result{CreateNew: true}, nil + } + return Result{Project: fm.result}, nil +} diff --git a/veans/internal/picker/tree.go b/veans/internal/picker/tree.go new file mode 100644 index 000000000..1835badea --- /dev/null +++ b/veans/internal/picker/tree.go @@ -0,0 +1,78 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package picker renders an interactive, hierarchical, fuzzy-searchable +// project picker for `veans init`. The pure tree/flatten logic is split from +// the bubbletea TUI so it stays unit-testable. +package picker + +import ( + "sort" + + "code.vikunja.io/veans/internal/client" +) + +type node struct { + project *client.Project + depth int + children []*node +} + +// buildForest turns a flat project slice into a depth-annotated forest. A +// project whose ParentProjectID is absent from the input becomes a root — +// this mirrors the frontend's effective-parent behavior so children of a +// hidden or archived parent don't vanish. Siblings are ordered by Position, +// tie-broken by Title. +func buildForest(projects []*client.Project) []*node { + byID := make(map[int64]*node, len(projects)) + for _, p := range projects { + if p == nil { + continue + } + byID[p.ID] = &node{project: p} + } + + var roots []*node + for _, p := range projects { + if p == nil { + continue + } + n := byID[p.ID] + parent, ok := byID[p.ParentProjectID] + if p.ParentProjectID == 0 || !ok { + roots = append(roots, n) + continue + } + parent.children = append(parent.children, n) + } + + sortAndAssignDepth(roots, 0) + return roots +} + +func sortAndAssignDepth(nodes []*node, depth int) { + sort.SliceStable(nodes, func(i, j int) bool { + a, b := nodes[i].project, nodes[j].project + if a.Position != b.Position { + return a.Position < b.Position + } + return a.Title < b.Title + }) + for _, n := range nodes { + n.depth = depth + sortAndAssignDepth(n.children, depth+1) + } +} diff --git a/veans/internal/picker/tree_test.go b/veans/internal/picker/tree_test.go new file mode 100644 index 000000000..d2874f949 --- /dev/null +++ b/veans/internal/picker/tree_test.go @@ -0,0 +1,129 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package picker + +import ( + "reflect" + "strconv" + "testing" + + "code.vikunja.io/veans/internal/client" +) + +func proj(id, parent int64, pos float64, title string) *client.Project { + return &client.Project{ID: id, ParentProjectID: parent, Position: pos, Title: title} +} + +// titlesWithDepth flattens a forest depth-first into "title@depth" tokens. +func titlesWithDepth(forest []*node) []string { + var out []string + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + out = append(out, n.project.Title+"@"+strconv.Itoa(n.depth)) + walk(n.children) + } + } + walk(forest) + return out +} + +func TestBuildForest_SingleRoot(t *testing.T) { + forest := buildForest([]*client.Project{proj(1, 0, 1, "Root")}) + got := titlesWithDepth(forest) + want := []string{"Root@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_Nested(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 1, 1, "Child"), + proj(3, 2, 1, "Grandchild"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Child@1", "Grandchild@2"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_MultipleRoots(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 2, "Beta"), + proj(2, 0, 1, "Alpha"), + }) + got := titlesWithDepth(forest) + // Roots are sorted by position: Alpha (pos 1) before Beta (pos 2). + want := []string{"Alpha@0", "Beta@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_SiblingOrderPositionThenTitle(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 0, "Root"), + proj(2, 1, 2, "C"), + proj(3, 1, 1, "B"), + // same position as B — tie-break by title puts A before B. + proj(4, 1, 1, "A"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "A@1", "B@1", "C@1"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_OrphanBecomesRoot(t *testing.T) { + // Parent 99 is not in the input set — child should surface as a root. + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "Root"), + proj(2, 99, 2, "Orphan"), + }) + got := titlesWithDepth(forest) + want := []string{"Root@0", "Orphan@0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestBuildForest_DepthCorrectness(t *testing.T) { + forest := buildForest([]*client.Project{ + proj(1, 0, 1, "A"), + proj(2, 1, 1, "B"), + proj(3, 2, 1, "C"), + proj(4, 3, 1, "D"), + }) + depthOf := map[string]int{} + var walk func(nodes []*node) + walk = func(nodes []*node) { + for _, n := range nodes { + depthOf[n.project.Title] = n.depth + walk(n.children) + } + } + walk(forest) + for title, want := range map[string]int{"A": 0, "B": 1, "C": 2, "D": 3} { + if depthOf[title] != want { + t.Errorf("depth of %q = %d, want %d", title, depthOf[title], want) + } + } +} From e271f75cad3030478af13a1cead94446f3feb61a Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 9 Jun 2026 14:21:29 +0200 Subject: [PATCH 34/45] feat(init): use the hierarchical fuzzy picker for project selection Replaces the flat numbered project list during 'veans init' with the interactive picker. --project still bypasses it; non-TTY stdin fails cleanly asking for --project. --- veans/internal/bootstrap/bootstrap.go | 43 +++++++++------------------ 1 file changed, 14 insertions(+), 29 deletions(-) diff --git a/veans/internal/bootstrap/bootstrap.go b/veans/internal/bootstrap/bootstrap.go index 3a9381e6e..a1251be89 100644 --- a/veans/internal/bootstrap/bootstrap.go +++ b/veans/internal/bootstrap/bootstrap.go @@ -31,7 +31,6 @@ import ( "io" "os" "regexp" - "sort" "strconv" "strings" @@ -40,6 +39,7 @@ import ( "code.vikunja.io/veans/internal/config" "code.vikunja.io/veans/internal/credentials" "code.vikunja.io/veans/internal/output" + "code.vikunja.io/veans/internal/picker" "code.vikunja.io/veans/internal/status" ) @@ -388,44 +388,29 @@ func pickProject(ctx context.Context, c *client.Client, id int64, p auth.Prompte } active = append(active, pr) } - sort.Slice(active, func(i, j int) bool { return active[i].Title < active[j].Title }) - - // The "create a new project" option sits at len(active)+1 in the menu; - // when the user has nothing to pick from, it's the only choice. - createIdx := len(active) + 1 if len(active) == 0 { fmt.Fprintln(out, "No projects yet — let's create one.") return createProject(ctx, c, p, out) } - fmt.Fprintln(out, "Available projects:") - for i, pr := range active { - ident := pr.Identifier - if ident == "" { - ident = "(no identifier)" - } - fmt.Fprintf(out, " [%d] #%d %s — %s\n", i+1, pr.ID, pr.Title, ident) - } - fmt.Fprintf(out, " [%d] Create a new project\n", createIdx) - - choice, err := p.ReadLine("Pick a project [1]: ") - if err != nil { + // picker.Pick reads os.Stdin directly via bubbletea. The prompter's + // buffered reader is idle here (all earlier prompts blocked at a + // newline in canonical mode), so there's no buffered input to lose; + // the terminal is restored to canonical mode when Pick returns. + res, err := picker.Pick(active) + switch { + case errors.Is(err, picker.ErrCanceled): + return nil, output.New(output.CodeValidation, "project selection canceled") + case errors.Is(err, picker.ErrNotATerminal): + return nil, output.New(output.CodeValidation, "not a terminal — pass --project ") + case err != nil: return nil, err } - choice = strings.TrimSpace(choice) - idx := 1 - if choice != "" { - v, err := strconv.Atoi(choice) - if err != nil || v < 1 || v > createIdx { - return nil, output.New(output.CodeValidation, "invalid project choice %q", choice) - } - idx = v - } - if idx == createIdx { + if res.CreateNew { return createProject(ctx, c, p, out) } - return active[idx-1], nil + return res.Project, nil } // createProject prompts for the new project's title and identifier and From da3bf0e7cd1fb7f72f4ea259890f0151d2d3ffa1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:11:43 +0200 Subject: [PATCH 35/45] docs(api/v2): tag CalDAV token fields for the v2 schema --- pkg/user/token.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/user/token.go b/pkg/user/token.go index 565270289..ee4e844da 100644 --- a/pkg/user/token.go +++ b/pkg/user/token.go @@ -41,12 +41,12 @@ const ( // Token is a token a user can use to do things like verify their email or resetting their password type Token struct { - ID int64 `xorm:"bigint autoincr not null unique pk" json:"id"` + ID int64 `xorm:"bigint autoincr not null unique pk" json:"id" readOnly:"true" doc:"The unique, numeric id of this token."` UserID int64 `xorm:"not null" json:"-"` Token string `xorm:"varchar(450) not null index" json:"-"` - ClearTextToken string `xorm:"-" json:"token"` + ClearTextToken string `xorm:"-" json:"token" readOnly:"true" doc:"The token in clear text. Only returned once when the token is created; never on subsequent reads."` Kind TokenKind `xorm:"not null" json:"-"` - Created time.Time `xorm:"created not null" json:"created"` + Created time.Time `xorm:"created not null" json:"created" readOnly:"true" doc:"A timestamp when this token was created. You cannot change this value."` } // TableName returns the real table name for user tokens From a562f69f02f49b2365b32c8e0de5cdd5449cacd1 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:11:43 +0200 Subject: [PATCH 36/45] feat(api/v2): add CalDAV tokens on /api/v2 --- pkg/routes/api/v2/caldav_tokens.go | 121 ++++++++++++++++++ pkg/webtests/huma_caldav_token_test.go | 165 +++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 pkg/routes/api/v2/caldav_tokens.go create mode 100644 pkg/webtests/huma_caldav_token_test.go diff --git a/pkg/routes/api/v2/caldav_tokens.go b/pkg/routes/api/v2/caldav_tokens.go new file mode 100644 index 000000000..b8cfbc19c --- /dev/null +++ b/pkg/routes/api/v2/caldav_tokens.go @@ -0,0 +1,121 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" +) + +// CalDAV tokens are scoped to the authenticated user, not a CRUDable resource: +// there is no per-token Can* method, so these handlers own their own user lookup +// (user.GetFromAuth refuses link shares) and session/commit lives in the user package. + +type caldavTokenListBody struct { + Body Paginated[*user.Token] +} + +type caldavTokenBody struct { + Body *user.Token +} + +// RegisterCalDAVTokenRoutes wires the current user's CalDAV token operations onto the Huma API. +func RegisterCalDAVTokenRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-create", + Summary: "Generate a CalDAV token", + Description: "Generates a CalDAV token for the authenticated user. The clear-text token is returned only in this response and can never be retrieved again. Link shares cannot have CalDAV tokens.", + Method: http.MethodPost, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensCreate) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-list", + Summary: "List CalDAV tokens", + Description: "Returns the authenticated user's CalDAV tokens. Only the id and creation date are returned — never the token value, which is shown once on creation.", + Method: http.MethodGet, + Path: "/user/settings/token/caldav", + Tags: tags, + }, caldavTokensList) + + Register(api, huma.Operation{ + OperationID: "caldav-tokens-delete", + Summary: "Delete a CalDAV token", + Description: "Deletes one of the authenticated user's CalDAV tokens by id. Tokens of other users are out of scope and cannot be deleted.", + Method: http.MethodDelete, + Path: "/user/settings/token/caldav/{id}", + Tags: tags, + }, caldavTokensDelete) +} + +func init() { AddRouteRegistrar(RegisterCalDAVTokenRoutes) } + +func caldavTokensCreate(ctx context.Context, _ *struct{}) (*caldavTokenBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + token, err := user.GenerateNewCaldavToken(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenBody{Body: token}, nil +} + +func caldavTokensList(ctx context.Context, in *ListParams) (*caldavTokenListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + tokens, err := user.GetCaldavTokens(u) + if err != nil { + return nil, translateDomainError(err) + } + return &caldavTokenListBody{Body: NewPaginated(tokens, int64(len(tokens)), in.Page, in.PerPage)}, nil +} + +func caldavTokensDelete(ctx context.Context, in *struct { + ID int64 `path:"id" doc:"The numeric id of the CalDAV token to delete."` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + u, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + if err := user.DeleteCaldavTokenByID(u, in.ID); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_caldav_token_test.go b/pkg/webtests/huma_caldav_token_test.go new file mode 100644 index 000000000..f8e2663ee --- /dev/null +++ b/pkg/webtests/huma_caldav_token_test.go @@ -0,0 +1,165 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "strconv" + "testing" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaCalDAVToken covers the v2 CalDAV token lifecycle. All calls share one +// echo env because setupTestEnv rotates the JWT signing key per call, which would +// 401 a token minted against an earlier env. +// +// Fixture (pkg/db/fixtures/user_tokens.yml): token id 6, kind 4 (CalDAV), +// belongs to user10. user1 starts with no CalDAV tokens. +func TestHumaCalDAVToken(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + user1Token := humaTokenFor(t, &testuser1) + user10Token := humaTokenFor(t, &testuser10) + + t.Run("Create returns the clear-text token", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + var created struct { + ID int64 `json:"id"` + Token string `json:"token"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &created), "body: %s", rec.Body.String()) + assert.NotZero(t, created.ID) + assert.NotEmpty(t, created.Token, "the clear-text token must be returned on create") + }) + + t.Run("List omits the token value", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + ids := caldavTokenIDsFromList(t, rec.Body.Bytes()) + assert.NotEmpty(t, ids, "the token created above must show up in the list") + assert.Empty(t, caldavTokenValuesFromList(t, rec.Body.Bytes()), + "the clear-text token must never appear in the list; body: %s", rec.Body.String()) + }) + + t.Run("List is scoped to the current user", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's fixture token #6 must be listed; body: %s", rec.Body.String()) + }) + + t.Run("Delete removes the token", func(t *testing.T) { + listRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, listRec.Code, "body: %s", listRec.Body.String()) + ids := caldavTokenIDsFromList(t, listRec.Body.Bytes()) + require.NotEmpty(t, ids) + + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/"+strconv.FormatInt(ids[0], 10), "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + afterRec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user1Token, "") + require.Equal(t, http.StatusOK, afterRec.Code, "body: %s", afterRec.Body.String()) + assert.NotContains(t, caldavTokenIDsFromList(t, afterRec.Body.Bytes()), ids[0], + "the deleted token must be gone; body: %s", afterRec.Body.String()) + }) + + t.Run("Delete is scoped to the current user", func(t *testing.T) { + // Token #6 belongs to user10; user1 deleting it is a no-op (204), not an error. + del := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", user1Token, "") + require.Equal(t, http.StatusNoContent, del.Code, "body: %s", del.Body.String()) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", user10Token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, caldavTokenIDsFromList(t, rec.Body.Bytes()), int64(6), + "user10's token #6 must survive a delete attempt by another user; body: %s", rec.Body.String()) + }) +} + +// TestHumaCalDAVToken_LinkShareForbidden ports v1's implicit guard: a link share +// is not a user, so create / list / delete all refuse it (403). +func TestHumaCalDAVToken_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + t.Run("create", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("list", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/token/caldav", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("delete", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodDelete, "/api/v2/user/settings/token/caldav/6", "", token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func caldavTokenIDsFromList(t *testing.T, body []byte) []int64 { + t.Helper() + items := caldavTokenItemsFromList(t, body) + ids := make([]int64, 0, len(items)) + for _, it := range items { + ids = append(ids, it.ID) + } + return ids +} + +func caldavTokenValuesFromList(t *testing.T, body []byte) []string { + t.Helper() + values := []string{} + for _, it := range caldavTokenItemsFromList(t, body) { + if it.Token != "" { + values = append(values, it.Token) + } + } + return values +} + +func caldavTokenItemsFromList(t *testing.T, body []byte) []struct { + ID int64 `json:"id"` + Token string `json:"token"` +} { + t.Helper() + var resp struct { + Items []struct { + ID int64 `json:"id"` + Token string `json:"token"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal(body, &resp), "list body must be a paginated envelope: %s", string(body)) + return resp.Items +} From 4afcfa44416fa0a30204d200bf105aeaef858a92 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:14:52 +0200 Subject: [PATCH 37/45] docs(api/v2): tag TOTP fields for the v2 schema --- pkg/user/totp.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/user/totp.go b/pkg/user/totp.go index 66abb813c..e18948443 100644 --- a/pkg/user/totp.go +++ b/pkg/user/totp.go @@ -37,11 +37,11 @@ import ( type TOTP struct { ID int64 `xorm:"bigint autoincr not null unique pk" json:"-"` UserID int64 `xorm:"bigint not null" json:"-"` - Secret string `xorm:"text not null" json:"secret"` + Secret string `xorm:"text not null" json:"secret" readOnly:"true" doc:"The shared secret used to generate passcodes, generated by the server on enrollment."` // The totp entry will only be enabled after the user verified they have a working totp setup. - Enabled bool `xorm:"null" json:"enabled"` + Enabled bool `xorm:"null" json:"enabled" readOnly:"true" doc:"Whether totp is fully activated. Set to true only after the user confirms a passcode."` // The totp url used to be able to enroll the user later - URL string `xorm:"text null" json:"url"` + URL string `xorm:"text null" json:"url" readOnly:"true" doc:"The otpauth:// url, generated by the server, used to enroll the user in an authenticator app."` } // TableName holds the table name for totp secrets From 190fab8e6d7d77b5ac82ac2609bbce6af4227ea3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:14:57 +0200 Subject: [PATCH 38/45] feat(api/v2): add TOTP 2FA on /api/v2 Ports the current-user TOTP (2FA) endpoints from /api/v1 to the Huma-backed /api/v2: get status, enroll, enable, and disable. Each is a custom, current-user-scoped handler that resolves the authenticated user and refuses non-local (OIDC/LDAP) accounts, preserving v1's local-account-only guard. The image/jpeg QR-code endpoint is intentionally not ported here; it is a binary-streaming route deferred to a later wave. --- pkg/routes/api/v2/user_totp.go | 210 ++++++++++++++++++++++++++++ pkg/webtests/huma_user_totp_test.go | 135 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 pkg/routes/api/v2/user_totp.go create mode 100644 pkg/webtests/huma_user_totp_test.go diff --git a/pkg/routes/api/v2/user_totp.go b/pkg/routes/api/v2/user_totp.go new file mode 100644 index 000000000..d998a524e --- /dev/null +++ b/pkg/routes/api/v2/user_totp.go @@ -0,0 +1,210 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type totpStatusBody struct { + Body *user.TOTP +} + +type totpEnableBody struct { + Body struct { + Passcode string `json:"passcode" doc:"The current totp passcode, used to confirm the authenticator is set up correctly."` + } +} + +type totpDisableBody struct { + Body struct { + Password string `json:"password" doc:"The current user's password, required to disable totp."` + } +} + +type totpMessageBody struct { + Body models.Message +} + +// RegisterTOTPRoutes wires the current-user totp (2FA) operations onto the Huma +// API. Totp is a local-account feature; every handler rejects OIDC/LDAP users. +// The QR-code blob endpoint is intentionally not ported here (binary streaming, +// handled in a later wave). +func RegisterTOTPRoutes(api huma.API) { + if !config.ServiceEnableTotp.GetBool() { + return + } + + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "totp-get", + Summary: "Get totp status", + Description: "Returns the authenticated user's current totp setting. Fails with 412 if totp was never enrolled. Local accounts only.", + Method: http.MethodGet, + Path: "/user/settings/totp", + Tags: tags, + }, totpGet) + + Register(api, huma.Operation{ + OperationID: "totp-enroll", + Summary: "Enroll into totp", + Description: "Creates the totp secret for the authenticated user. The setup must still be confirmed via the enable endpoint before it takes effect. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enroll", + // v1 returns 200 here, not 201: enrollment is an inactive draft, not a usable resource yet. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnroll) + + Register(api, huma.Operation{ + OperationID: "totp-enable", + Summary: "Enable totp", + Description: "Activates a previously enrolled totp setting by confirming a passcode. All existing sessions are invalidated. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/enable", + // Confirms an existing enrollment; creates no new resource. + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpEnable) + + Register(api, huma.Operation{ + OperationID: "totp-disable", + Summary: "Disable totp", + Description: "Removes all totp settings for the authenticated user. Requires the current password for confirmation. Local accounts only.", + Method: http.MethodPost, + Path: "/user/settings/totp/disable", + DefaultStatus: http.StatusOK, + Tags: tags, + }, totpDisable) +} + +func init() { AddRouteRegistrar(RegisterTOTPRoutes) } + +// localUserFromCtx resolves the authenticated user and refuses anything that is +// not a local account, mirroring v1's getLocalUserFromContext. The caller owns +// the returned session. CheckUserPassword and IsLocalUser need the full DB +// record (password hash, issuer), so this loads it rather than trusting the +// token claims. +func localUserFromCtx(ctx context.Context) (*user.User, *xorm.Session, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, nil, err + } + + s := db.NewSession() + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + s.Close() + return nil, nil, translateDomainError(err) + } + // A link share resolves to a synthetic, non-local user; any other auth type + // yields nil. Both must be refused — totp is a real-account-only feature. + if u == nil || !u.IsLocalUser() { + s.Close() + return nil, nil, translateDomainError(&user.ErrAccountIsNotLocal{}) + } + + return u, s, nil +} + +func totpGet(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.GetTOTPForUser(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnroll(ctx context.Context, _ *struct{}) (*totpStatusBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + t, err := user.EnrollTOTP(s, u) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpStatusBody{Body: t}, nil +} + +func totpEnable(ctx context.Context, in *totpEnableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.EnableTOTP(s, &user.TOTPPasscode{User: u, Passcode: in.Body.Passcode}); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := models.DeleteAllUserSessions(s, u.ID); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was enabled successfully."}}, nil +} + +func totpDisable(ctx context.Context, in *totpDisableBody) (*totpMessageBody, error) { + u, s, err := localUserFromCtx(ctx) + if err != nil { + return nil, err + } + defer s.Close() + + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := user.DisableTOTP(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &totpMessageBody{Body: models.Message{Message: "TOTP was disabled successfully."}}, nil +} diff --git a/pkg/webtests/huma_user_totp_test.go b/pkg/webtests/huma_user_totp_test.go new file mode 100644 index 000000000..d5cc82f15 --- /dev/null +++ b/pkg/webtests/huma_user_totp_test.go @@ -0,0 +1,135 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "fmt" + "net/http" + "testing" + "time" + + "code.vikunja.io/api/pkg/user" + + "github.com/pquerna/otp/totp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testuser14 is a non-local (OIDC) account; totp is local-only, so every totp +// route must refuse it. See pkg/db/fixtures/users.yml. +var testuser14 = user.User{ID: 14, Username: "user14", Issuer: "https://some.service.com"} + +// TestHumaTOTP mirrors v1's TestUserTOTPLocalUser and adds the enable/disable +// flows plus the local-account-only guard. The QR-code endpoint is not ported +// to v2 (binary streaming, later wave), so there is no test for it here. +// +// Fixture topology (pkg/db/fixtures/totp.yml + users.yml): +// - user1: totp enrolled, not enabled (secret HXDMVJEC…). +// - user10: totp enabled (secret JBSWY3DP…), local, password 12345678. +// - user15: local, no totp enrollment. +// - user14: non-local (OIDC) account. +func TestHumaTOTP(t *testing.T) { + t.Run("Get status for enrolled user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Get status without enrollment returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/totp", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Enroll a fresh user", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user15 has no totp enrollment in the fixtures. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser15), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"secret"`) + assert.Contains(t, rec.Body.String(), `"url"`) + assert.Contains(t, rec.Body.String(), `"enabled":false`) + }) + + t.Run("Enroll when already enrolled returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enroll", "", humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Enable with a valid passcode", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user1's fixture secret; generate a passcode that is valid right now. + passcode, err := totp.GenerateCode("HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ", time.Now()) + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + fmt.Sprintf(`{"passcode":%q}`, passcode), humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "enabled successfully") + }) + + t.Run("Enable with an invalid passcode returns 412", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/enable", + `{"passcode":"000000"}`, humaTokenFor(t, &testuser1), "") + require.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Disable with the correct password", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + // user10 has totp enabled; 12345678 is their fixture password. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"12345678"}`, humaTokenFor(t, &testuser10), "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "disabled successfully") + }) + + t.Run("Disable with a wrong password is refused", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/settings/totp/disable", + `{"password":"wrong-password"}`, humaTokenFor(t, &testuser10), "") + require.NotEqual(t, http.StatusOK, rec.Code, "wrong password must not disable totp; body: %s", rec.Body.String()) + }) + + t.Run("Non-local user is refused on every route", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser14) + for _, tc := range []struct { + method, path, body string + }{ + {http.MethodGet, "/api/v2/user/settings/totp", ""}, + {http.MethodPost, "/api/v2/user/settings/totp/enroll", ""}, + {http.MethodPost, "/api/v2/user/settings/totp/enable", `{"passcode":"000000"}`}, + {http.MethodPost, "/api/v2/user/settings/totp/disable", `{"password":"12345678"}`}, + } { + rec := humaRequest(t, e, tc.method, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "%s %s must refuse a non-local account; body: %s", tc.method, tc.path, rec.Body.String()) + } + }) +} From a610ccbbac67d073600fdf49d0a2bb26b7528641 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:17:50 +0200 Subject: [PATCH 39/45] feat(api/v2): add user webhooks on /api/v2 Port the per-user webhook endpoints (/user/settings/webhooks) from /api/v1 to the Huma-backed /api/v2: list, available events, create, update, delete. They are the project-less sibling of the project webhooks (#2858) and share the webhooks.enabled gate, checked inside the registrar. Webhook.ReadAll is extended to serve the user-level list (scoped to the authenticated user) so the v2 list handler can go through handler.DoReadAll like the project list; the project branch is unchanged. Credentials are masked on read via the model's existing maskCredentials, matching #2858. --- pkg/db/fixtures/webhooks.yml | 38 +++++ pkg/models/webhooks.go | 31 ++-- pkg/routes/api/v2/user_webhooks.go | 167 ++++++++++++++++++++++ pkg/webtests/huma_user_webhook_test.go | 189 +++++++++++++++++++++++++ 4 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 pkg/routes/api/v2/user_webhooks.go create mode 100644 pkg/webtests/huma_user_webhook_test.go diff --git a/pkg/db/fixtures/webhooks.yml b/pkg/db/fixtures/webhooks.yml index 4ec5687c7..983a03aff 100644 --- a/pkg/db/fixtures/webhooks.yml +++ b/pkg/db/fixtures/webhooks.yml @@ -41,3 +41,41 @@ created_by_id: 3 created: 2024-01-01 00:00:00 updated: 2024-01-01 00:00:00 +# Webhooks 6-8 are user-level (project_id null, user_id set) and back the v2 +# user-webhook tests. #6/#7 belong to user6; #6 carries credentials so masking +# can be asserted. #8 belongs to user1 so the owner-isolation check (user6 must +# not see or mutate another user's webhook) has a target. +# +# Event choice matters because the pkg/e2etests user-webhook suite shares these +# fixtures and dispatches real events. The WebhookListener fans a fired event out +# to ALL of the event-user's webhooks, asynchronously; a user-level fixture +# subscribed to a user-directed event the suite dispatches for its owner fires a +# real (failing) delivery to example.com, and that in-flight write then races the +# next test's fixture reload ("database table is locked: webhooks"). The suite +# dispatches user-directed events only for user1, so #6/#7 are owned by user6, and +# #8 (owned by user1) subscribes to task.updated — a project-only event the +# listener never matches for user webhooks. None of the three can fire there. +- id: 6 + target_url: "https://example.com/user-webhook-fixture" + events: '["task.reminder.fired"]' + user_id: 6 + secret: "uwh-secret-fixture" + basic_auth_user: "uwh-basicauth-user" + basic_auth_password: "uwh-basicauth-pass" + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 7 + target_url: "https://example.com/user-webhook-second" + events: '["task.reminder.fired"]' + user_id: 6 + created_by_id: 6 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 +- id: 8 + target_url: "https://example.com/user-webhook-other" + events: '["task.updated"]' + user_id: 1 + created_by_id: 1 + created: 2024-01-01 00:00:00 + updated: 2024-01-01 00:00:00 diff --git a/pkg/models/webhooks.go b/pkg/models/webhooks.go index fd7ee8e81..b4038bf6c 100644 --- a/pkg/models/webhooks.go +++ b/pkg/models/webhooks.go @@ -40,6 +40,7 @@ import ( "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web" + "xorm.io/builder" "xorm.io/xorm" ) @@ -216,24 +217,36 @@ func (w *Webhook) Create(s *xorm.Session, a web.Auth) (err error) { // @Failure 500 {object} models.Message "Internal server error" // @Router /projects/{id}/webhooks [get] func (w *Webhook) ReadAll(s *xorm.Session, a web.Auth, _ string, page int, perPage int) (result interface{}, resultCount int, numberOfTotalItems int64, err error) { - p := &Project{ID: w.ProjectID} - can, _, err := p.CanRead(s, a) - if err != nil { - return nil, 0, 0, err - } - if !can { - return nil, 0, 0, ErrGenericForbidden{} + // w.UserID set selects the user-level list: a user may only see their own + // webhooks. The project list (w.UserID == 0) delegates to the project's read + // permission instead. + var listCond builder.Cond + if w.UserID > 0 { + if _, isShareAuth := a.(*LinkSharing); isShareAuth || w.UserID != a.GetID() { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"user_id": w.UserID} + } else { + p := &Project{ID: w.ProjectID} + can, _, cerr := p.CanRead(s, a) + if cerr != nil { + return nil, 0, 0, cerr + } + if !can { + return nil, 0, 0, ErrGenericForbidden{} + } + listCond = builder.Eq{"project_id": w.ProjectID} } ws := []*Webhook{} - err = s.Where("project_id = ?", w.ProjectID). + err = s.Where(listCond). Limit(getLimitFromPageIndex(page, perPage)). Find(&ws) if err != nil { return } - total, err := s.Where("project_id = ?", w.ProjectID). + total, err := s.Where(listCond). Count(&Webhook{}) if err != nil { return diff --git a/pkg/routes/api/v2/user_webhooks.go b/pkg/routes/api/v2/user_webhooks.go new file mode 100644 index 000000000..b35407c79 --- /dev/null +++ b/pkg/routes/api/v2/user_webhooks.go @@ -0,0 +1,167 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "fmt" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/web/handler" + + "github.com/danielgtaylor/huma/v2" +) + +// models.Webhook.ReadAll returns []*models.Webhook, so that's the element type. +type userWebhookListBody struct { + Body Paginated[*models.Webhook] +} + +type userWebhookEventsBody struct { + Body []string +} + +// RegisterUserWebhookRoutes wires the per-user webhook CRUD onto the Huma API. +// User webhooks are the project-less sibling of the project webhooks (see +// webhooks.go): they fire across all of a user's projects and are owned by the +// user, not a project. Both resources share the webhooks.enabled gate; the check +// runs here (not at init()) because RegisterAll fires after config is loaded. +// Like project webhooks there is deliberately no ReadOne — webhooks carry +// credentials — so AutoPatch synthesises no PATCH and update is PUT only. +func RegisterUserWebhookRoutes(api huma.API) { + if !config.WebhooksEnabled.GetBool() { + return + } + + tags := []string{"webhooks"} + + Register(api, huma.Operation{ + OperationID: "user-webhooks-list", + Summary: "List the current user's webhooks", + Description: "Returns the webhook targets the authenticated user has configured for themselves (not project webhooks), paginated. Secret and basic-auth credentials are never included.", + Method: http.MethodGet, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksList) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-events", + Summary: "List available user-directed webhook events", + Description: "Returns the webhook event names a user-level webhook may subscribe to. This is a subset of the project webhook events — only events that target a single user.", + Method: http.MethodGet, + Path: "/user/settings/webhooks/events", + Tags: tags, + }, userWebhooksEvents) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-create", + Summary: "Create a webhook for the current user", + Description: "Creates a webhook target owned by the authenticated user that receives POST requests across all of their projects. The owning user is taken from the token, not the body. May only subscribe to user-directed events (see the events route). The secret and basic-auth credentials are write-only and not returned in the response.", + Method: http.MethodPost, + Path: "/user/settings/webhooks", + Tags: tags, + }, userWebhooksCreate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-update", + Summary: "Update a user webhook's events", + Description: "Changes the events a user webhook subscribes to. Only the events list can be changed; target_url, secret and auth are immutable after creation. Only the owning user may update it.", + Method: http.MethodPut, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksUpdate) + + Register(api, huma.Operation{ + OperationID: "user-webhooks-delete", + Summary: "Delete a user webhook", + Description: "Deletes a webhook owned by the authenticated user. Only the owning user may delete it.", + Method: http.MethodDelete, + Path: "/user/settings/webhooks/{webhook}", + Tags: tags, + }, userWebhooksDelete) +} + +func init() { AddRouteRegistrar(RegisterUserWebhookRoutes) } + +func userWebhooksList(ctx context.Context, in *ListParams) (*userWebhookListBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + result, _, total, err := handler.DoReadAll(ctx, &models.Webhook{UserID: a.GetID()}, a, in.Q, in.Page, in.PerPage) + if err != nil { + return nil, translateDomainError(err) + } + items, ok := result.([]*models.Webhook) + if !ok { + return nil, fmt.Errorf("webhooks.ReadAll returned unexpected type %T (expected []*models.Webhook)", result) + } + return &userWebhookListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil +} + +func userWebhooksEvents(_ context.Context, _ *struct{}) (*userWebhookEventsBody, error) { + return &userWebhookEventsBody{Body: models.GetUserDirectedWebhookEvents()}, nil +} + +func userWebhooksCreate(ctx context.Context, in *struct { + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // Force user ownership: a user webhook is keyed on the user, never a project. + in.Body.UserID = a.GetID() + in.Body.ProjectID = 0 + if err := handler.DoCreate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksUpdate(ctx context.Context, in *struct { + ID int64 `path:"webhook"` + Body models.Webhook +}) (*singleBody[models.Webhook], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + // canDoWebhook resolves the owner from the stored row, so only the id is + // needed to gate the update; the rest of the body's ownership fields are + // ignored. Update persists only the events list. + in.Body.ID = in.ID + if err := handler.DoUpdate(ctx, &in.Body, a); err != nil { + return nil, translateDomainError(err) + } + return &singleBody[models.Webhook]{Body: &in.Body}, nil +} + +func userWebhooksDelete(ctx context.Context, in *struct { + ID int64 `path:"webhook"` +}) (*emptyBody, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + if err := handler.DoDelete(ctx, &models.Webhook{ID: in.ID}, a); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_user_webhook_test.go b/pkg/webtests/huma_user_webhook_test.go new file mode 100644 index 000000000..8c061a6ff --- /dev/null +++ b/pkg/webtests/huma_user_webhook_test.go @@ -0,0 +1,189 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestHumaUserWebhook ports the v1 user-webhook coverage (the per-user sibling of +// the project webhooks tested in TestHumaWebhook) to /api/v2. User webhooks live +// at /user/settings/webhooks{,/{webhook}} — list, events, create, update, delete; +// there is deliberately no ReadOne (webhooks carry credentials). +// +// Ownership gradient — a user webhook is owned by its UserID, and every Can* boils +// down to "are you that user". Fixtures: webhooks #6/#7 belong to user6, #8 to +// user1. The actor is user6 (not user1): the user-webhook e2e tests dispatch +// user-directed events only for users 1 and 2, so user6-owned fixtures never fire +// there. The point of these cases is that user6 sees and mutates only their own +// webhooks and is forbidden on user1's. +func TestHumaUserWebhook(t *testing.T) { + // availableWebhookEvents / userDirectedWebhookEvents are populated by + // RegisterListeners(), which the webtests harness does not call. Register the + // one user-directed event the fixtures and these cases use so Create/Update + // validation accepts it. + models.RegisterUserDirectedEventForWebhook(&models.TaskReminderFiredEvent{}) + + owner := webHandlerTestV2{ + user: &testuser6, + basePath: "/api/v2/user/settings/webhooks", + idParam: "webhook", + t: t, + } + require.NoError(t, owner.ensureEnv()) + + t.Run("ReadAll", func(t *testing.T) { + t.Run("Normal - sees only own webhooks", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + ids := webhookIDsFromReadAll(t, rec.Body.Bytes()) + // user6 owns #6 and #7; #8 belongs to user1 and must not appear. + assert.ElementsMatch(t, []int64{6, 7}, ids, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"target_url"`) + }) + t.Run("Secret and basic auth credentials are never exposed", func(t *testing.T) { + rec, err := owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.NotContains(t, rec.Body.String(), `uwh-secret-fixture`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-user`) + assert.NotContains(t, rec.Body.String(), `uwh-basicauth-pass`) + }) + }) + + t.Run("Events", func(t *testing.T) { + // The events route reports only user-directed events. task.reminder.fired + // is registered above; task.updated (project-only) must not be listed. + token := humaTokenFor(t, &testuser6) + rec := humaRequest(t, owner.e, http.MethodGet, "/api/v2/user/settings/webhooks/events", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + var events []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &events), "body: %s", rec.Body.String()) + assert.Contains(t, events, "task.reminder.fired") + assert.NotContains(t, events, "task.updated") + }) + + t.Run("Create", func(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/new","events":["task.reminder.fired"]}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.Contains(t, rec.Body.String(), `"target_url":"https://example.com/new"`) + // Ownership comes from the token, not the body. + assert.Contains(t, rec.Body.String(), `"user_id":6`) + }) + t.Run("Secret and basic auth are not echoed back", func(t *testing.T) { + rec, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/secret","events":["task.reminder.fired"],"secret":"top-secret","basic_auth_user":"u","basic_auth_password":"p"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusCreated, rec.Code) + assert.NotContains(t, rec.Body.String(), `top-secret`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_user":"u"`) + assert.NotContains(t, rec.Body.String(), `"basic_auth_password":"p"`) + }) + t.Run("Non user-directed event rejected", func(t *testing.T) { + // task.updated is a project event, not user-directed; Create rejects it + // → InvalidFieldError, surfaced as 422 on v2. + _, err := owner.testCreateWithUser(nil, nil, + `{"target_url":"https://example.com/x","events":["task.updated"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + t.Run("Missing target url", func(t *testing.T) { + _, err := owner.testCreateWithUser(nil, nil, `{"events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err)) + }) + }) + + t.Run("Update", func(t *testing.T) { + t.Run("Normal - only events change", func(t *testing.T) { + rec, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "6"}, + `{"events":["task.reminder.fired"],"target_url":"https://example.com/ignored"}`) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), `"id":6`) + + rec, err = owner.testReadAllWithUser(nil, nil) + require.NoError(t, err) + assert.Contains(t, rec.Body.String(), `https://example.com/user-webhook-fixture`, + "target_url must stay the fixture value; only events are mutable") + assert.NotContains(t, rec.Body.String(), `https://example.com/ignored`) + }) + t.Run("Cannot update another user's webhook", func(t *testing.T) { + // webhook #8 belongs to user1; canDoWebhook resolves ownership from the + // stored row, so user6 is forbidden regardless of the URL. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "8"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + // canDoWebhook returns false for a missing webhook → 403, not 404. + _, err := owner.testUpdateWithUser(nil, map[string]string{"webhook": "9999"}, + `{"target_url":"https://example.com/wh","events":["task.reminder.fired"]}`) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + }) + + t.Run("Delete", func(t *testing.T) { + t.Run("Cannot delete another user's webhook", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "8"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Nonexisting", func(t *testing.T) { + _, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "9999"}) + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err)) + }) + t.Run("Normal", func(t *testing.T) { + rec, err := owner.testDeleteWithUser(nil, map[string]string{"webhook": "7"}) + require.NoError(t, err) + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Empty(t, rec.Body.String()) + }) + }) +} + +// TestHumaUserWebhook_DisabledByConfig confirms RegisterUserWebhookRoutes skips +// the resource when webhooks.enabled is false, so the v2 user-webhook routes 404 +// rather than running with the feature toggled off. +func TestHumaUserWebhook_DisabledByConfig(t *testing.T) { + _, err := setupTestEnv() + require.NoError(t, err) + + config.WebhooksEnabled.Set(false) + defer config.WebhooksEnabled.Set(true) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + + token := humaTokenFor(t, &testuser1) + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/webhooks", "", token, "") + assert.Equal(t, http.StatusNotFound, rec.Code, "route must be absent when webhooks are disabled; body: %s", rec.Body.String()) +} From b8894ac1c1cac2e38fcc7b31085e249e16461953 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 15:12:53 +0200 Subject: [PATCH 40/45] feat(api/v2): add user account-deletion flow on /api/v2 --- pkg/routes/api/v2/user_deletion.go | 172 +++++++++++++++++++++ pkg/webtests/huma_user_deletion_test.go | 194 ++++++++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 pkg/routes/api/v2/user_deletion.go create mode 100644 pkg/webtests/huma_user_deletion_test.go diff --git a/pkg/routes/api/v2/user_deletion.go b/pkg/routes/api/v2/user_deletion.go new file mode 100644 index 000000000..1ecc5e009 --- /dev/null +++ b/pkg/routes/api/v2/user_deletion.go @@ -0,0 +1,172 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "xorm.io/xorm" +) + +type userDeletionPasswordBody struct { + Body struct { + Password string `json:"password" doc:"The authenticated user's password. Required for local users; ignored for users authenticated via an external provider."` + } +} + +type userDeletionConfirmBody struct { + Body struct { + Token string `json:"token" required:"true" minLength:"1" doc:"The deletion confirmation token from the email sent by the request-deletion endpoint."` + } +} + +func RegisterUserDeletionRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-deletion-request", + Summary: "Request account deletion", + Description: "Starts deletion of the authenticated user's account. Local users must provide their password; a confirmation email is then sent and deletion only proceeds once confirmed.", + Method: http.MethodPost, + Path: "/user/deletion/request", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionRequest) + + Register(api, huma.Operation{ + OperationID: "user-deletion-confirm", + Summary: "Confirm account deletion", + Description: "Confirms a requested account deletion using the token from the confirmation email and schedules the account for deletion.", + Method: http.MethodPost, + Path: "/user/deletion/confirm", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionConfirm) + + Register(api, huma.Operation{ + OperationID: "user-deletion-cancel", + Summary: "Cancel account deletion", + Description: "Cancels a scheduled account deletion. Local users must provide their password.", + Method: http.MethodPost, + Path: "/user/deletion/cancel", + Tags: tags, + DefaultStatus: http.StatusNoContent, + }, userDeletionCancel) +} + +func init() { AddRouteRegistrar(RegisterUserDeletionRoutes) } + +// authUserFromCtx resolves the full DB user for the authenticated caller, refusing +// link shares (which have no account to delete) with a 403. +func authUserFromCtx(ctx context.Context, s *xorm.Session) (*user.User, error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + authUser, is := a.(*user.User) + if !is { + return nil, huma.Error403Forbidden("only users can manage account deletion") + } + // The auth user from the JWT claims is partial; re-fetch for the password hash. + u, err := user.GetUserByID(s, authUser.ID) + if err != nil { + return nil, translateDomainError(err) + } + return u, nil +} + +func userDeletionRequest(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.RequestDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionConfirm(ctx context.Context, in *userDeletionConfirmBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if err := user.ConfirmDeletion(s, u, in.Body.Token); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} + +func userDeletionCancel(ctx context.Context, in *userDeletionPasswordBody) (*emptyBody, error) { + s := db.NewSession() + defer s.Close() + + u, err := authUserFromCtx(ctx, s) + if err != nil { + _ = s.Rollback() + return nil, err + } + + if u.IsLocalUser() { + if err := user.CheckUserPassword(u, in.Body.Password); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + } + + if err := user.CancelDeletion(s, u); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + return &emptyBody{}, nil +} diff --git a/pkg/webtests/huma_user_deletion_test.go b/pkg/webtests/huma_user_deletion_test.go new file mode 100644 index 000000000..081db594d --- /dev/null +++ b/pkg/webtests/huma_user_deletion_test.go @@ -0,0 +1,194 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" + "code.vikunja.io/api/pkg/user" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + userDeletionRequestPath = "/api/v2/user/deletion/request" + userDeletionConfirmPath = "/api/v2/user/deletion/confirm" + userDeletionCancelPath = "/api/v2/user/deletion/cancel" + // testUserPassword is the plaintext password for every local fixture user. + testUserPassword = "12345678" +) + +// deletionTokenFor reads the cleartext account-deletion token RequestDeletion +// stored for the user. RequestDeletion only mails the token, so the test pulls +// it straight from user_tokens (kind 3 = TokenAccountDeletion). +func deletionTokenFor(t *testing.T, userID int64) string { + t.Helper() + s := db.NewSession() + defer s.Close() + tok := struct { + Token string `xorm:"token"` + }{} + has, err := s.Table("user_tokens"). + Where("user_id = ? AND kind = ?", userID, 3). + Get(&tok) + require.NoError(t, err) + require.True(t, has, "RequestDeletion must have stored a deletion token for user %d", userID) + return tok.Token +} + +func deletionScheduledFor(t *testing.T, userID int64) bool { + t.Helper() + s := db.NewSession() + defer s.Close() + u, err := user.GetUserByID(s, userID) + require.NoError(t, err) + return !u.DeletionScheduledAt.IsZero() +} + +// TestHumaUserDeletion ports v1's account-deletion flow (request → confirm → +// cancel) to v2. v1 returned 200/204 with a confirmation message body; v2 +// normalises all three to an empty 204 (the action returns no resource), so +// every success here asserts 204 + empty body. +func TestHumaUserDeletion(t *testing.T) { + t.Run("Request - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "a rejected request must not schedule deletion") + }) + + t.Run("Confirm - invalid token rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":"not-a-real-token"}`, token, "") + assert.Equal(t, http.StatusBadRequest, rec.Code, "body: %s", rec.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID)) + }) + + t.Run("Confirm - missing token is a validation error", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, `{"token":""}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + + t.Run("Request then confirm schedules deletion", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + assert.Empty(t, req.Body.String(), "v2 normalises the request action to an empty 204") + assert.False(t, deletionScheduledFor(t, testuser1.ID), "request alone must not schedule; confirmation does") + + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + assert.Empty(t, confirm.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "confirm must schedule the deletion") + }) + + t.Run("Cancel - wrong password rejected", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Schedule first so there is something to cancel. + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"wrong"}`, token, "") + assert.Equal(t, http.StatusForbidden, cancel.Code, "body: %s", cancel.Body.String()) + assert.True(t, deletionScheduledFor(t, testuser1.ID), "a rejected cancel must leave the deletion scheduled") + }) + + t.Run("Cancel - correct password clears the schedule", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + req := humaRequest(t, e, http.MethodPost, userDeletionRequestPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, req.Code, "body: %s", req.Body.String()) + confirm := humaRequest(t, e, http.MethodPost, userDeletionConfirmPath, + `{"token":"`+deletionTokenFor(t, testuser1.ID)+`"}`, token, "") + require.Equal(t, http.StatusNoContent, confirm.Code, "body: %s", confirm.Body.String()) + require.True(t, deletionScheduledFor(t, testuser1.ID)) + + cancel := humaRequest(t, e, http.MethodPost, userDeletionCancelPath, `{"password":"`+testUserPassword+`"}`, token, "") + require.Equal(t, http.StatusNoContent, cancel.Code, "body: %s", cancel.Body.String()) + assert.Empty(t, cancel.Body.String()) + assert.False(t, deletionScheduledFor(t, testuser1.ID), "cancel must clear the scheduled deletion") + }) + + t.Run("Unauthenticated", func(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + for _, path := range []string{userDeletionRequestPath, userDeletionConfirmPath, userDeletionCancelPath} { + rec := humaRequest(t, e, http.MethodPost, path, `{}`, "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code, "%s body: %s", path, rec.Body.String()) + } + }) +} + +// TestHumaUserDeletion_LinkShareForbidden asserts a link share — which has no +// account — is refused (403) on every deletion action. +func TestHumaUserDeletion_LinkShareForbidden(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token, err := auth.NewLinkShareJWTAuthtoken(&models.LinkSharing{ + ID: 1, + Hash: "test", + ProjectID: 1, + Permission: models.PermissionRead, + SharingType: models.SharingTypeWithoutPassword, + SharedByID: 1, + }) + require.NoError(t, err) + + for _, tc := range []struct { + name string + path string + body string + }{ + {"request", userDeletionRequestPath, `{"password":"` + testUserPassword + `"}`}, + {"confirm", userDeletionConfirmPath, `{"token":"x"}`}, + {"cancel", userDeletionCancelPath, `{"password":"` + testUserPassword + `"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, tc.path, tc.body, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + } +} From 154a96674d6ffb9c131b5fb03bba7f3683776f44 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:34:42 +0200 Subject: [PATCH 41/45] fix(notifications): strip remote images from notification emails User-controlled fields rendered into notification emails (task title via the conversational header, comment and description bodies) were sanitized with a bluemonday UGCPolicy that permits remote sources. An attacker with write access to a shared project could therefore inject an external image that acts as a tracking pixel in a subscriber's inbox, leaking email-open time and IP. Restrict notification-email images to inline data URIs (used by avatars) by adding a RewriteSrc hook that blanks any non-data image src. The policy was duplicated in three places, so extract it into newNotificationSanitizer. Refs GHSA-2vr2-r3qw-rjvq --- pkg/notifications/mail_render.go | 33 +++++++++++--------- pkg/notifications/mail_test.go | 52 ++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 15 deletions(-) diff --git a/pkg/notifications/mail_render.go b/pkg/notifications/mail_render.go index 292927c80..7749e1d9c 100644 --- a/pkg/notifications/mail_render.go +++ b/pkg/notifications/mail_render.go @@ -20,6 +20,7 @@ import ( "bytes" "embed" templatehtml "html/template" + "net/url" "regexp" "strings" templatetext "text/template" @@ -187,16 +188,26 @@ const mailTemplateConversationalHTML = ` //go:embed logo.png var logo embed.FS -func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { +// newNotificationSanitizer builds the bluemonday policy for all HTML in notification +// emails. Only inline data-URI images (avatars) are allowed: RewriteSrc blanks any +// remote image src so a user-controlled task title, comment or description can't +// smuggle a tracking pixel into a recipient's inbox. +func newNotificationSanitizer() *bluemonday.Policy { p := bluemonday.UGCPolicy() - // Allow data URI images for inline avatars in mentions p.AllowDataURIImages() - // Allow style attribute on img and div elements for avatar and layout styling p.AllowAttrs("style").OnElements("img", "div") - // Allow specific CSS properties for avatar styling p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") - // Allow padding styles on div elements for content spacing p.AllowStyles("padding-top", "margin-bottom").OnElements("div") + p.RewriteSrc(func(u *url.URL) { + if u.Scheme != "data" { + *u = url.URL{} + } + }) + return p +} + +func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { + p := newNotificationSanitizer() for _, line := range lines { if line.isHTML { @@ -225,11 +236,7 @@ func convertLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err e // sanitizeLinesToHTML sanitizes lines without wrapping in

tags or adding margins. // Used for footer lines and other content that should not have paragraph styling. func sanitizeLinesToHTML(lines []*mailLine) (linesHTML []templatehtml.HTML, err error) { - p := bluemonday.UGCPolicy() - p.AllowDataURIImages() - p.AllowAttrs("style").OnElements("img", "div") - p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") - p.AllowStyles("padding-top", "margin-bottom").OnElements("div") + p := newNotificationSanitizer() for _, line := range lines { if line.isHTML { @@ -366,12 +373,8 @@ func RenderMail(m *Mail, lang string) (mailOpts *mail.Opts, err error) { data["CopyURLText"] = i18n.T(lang, "notifications.common.copy_url") if m.headerLine != nil { - p := bluemonday.UGCPolicy() - p.AllowDataURIImages() - p.AllowAttrs("style").OnElements("img", "div") - p.AllowStyles("border-radius", "vertical-align", "margin-right").OnElements("img") // #nosec G203 -- the html is sanitized - data["HeaderLineHTML"] = templatehtml.HTML(p.Sanitize(m.headerLine.Text)) + data["HeaderLineHTML"] = templatehtml.HTML(newNotificationSanitizer().Sanitize(m.headerLine.Text)) } data["IntroLinesHTML"], err = convertLinesToHTML(m.introLines) diff --git a/pkg/notifications/mail_test.go b/pkg/notifications/mail_test.go index fca5c6447..d8f5db2e4 100644 --- a/pkg/notifications/mail_test.go +++ b/pkg/notifications/mail_test.go @@ -711,3 +711,55 @@ func TestConversationalMail(t *testing.T) { assert.Contains(t, headerLine1, "(Project > Task) #1") }) } + +// Regression test for GHSA-2vr2-r3qw-rjvq: a user-controlled task title (or comment +// or description) must not be able to smuggle a remote image into a notification +// email, where it would act as a tracking pixel. Inline data-URI avatars and normal +// links must keep working. +func TestNotificationEmailStripsRemoteImages(t *testing.T) { + const remoteSrc = "https://attacker.example/track.png?u=victim" + + t.Run("remote image injected via task title in header is stripped", func(t *testing.T) { + payloadTitle := `normal title` + header := CreateConversationalHeader("", "attacker left a comment", "https://example.com/task/1", "Project", "#1", payloadTitle) + + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HeaderLine(header). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc) + assert.NotContains(t, mailOpts.HTMLMessage, "attacker.example") + // The benign text is still delivered, and the legitimate task link survives. + assert.Contains(t, mailOpts.HTMLMessage, "normal title") + assert.Contains(t, mailOpts.HTMLMessage, `href="https://example.com/task/1"`) + }) + + t.Run("remote image in body content is stripped", func(t *testing.T) { + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HTML(`

hi

`). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.NotContains(t, mailOpts.HTMLMessage, remoteSrc) + assert.Contains(t, mailOpts.HTMLMessage, "hi") + }) + + t.Run("inline data-URI avatar is preserved", func(t *testing.T) { + const avatar = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + header := CreateConversationalHeader(avatar, "alice left a comment", "https://example.com/task/1", "Project", "#1", "Task") + + mailOpts, err := RenderMail(NewMail(). + Conversational(). + Subject("Test"). + HeaderLine(header). + Action("View Task", "https://example.com/task/1"), "en") + require.NoError(t, err) + + assert.Contains(t, mailOpts.HTMLMessage, "data:image/png;base64,") + }) +} From 46b07a019cbe50d92db2e9003a0a23173ee59d2d Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:16:53 +0200 Subject: [PATCH 42/45] refactor(user): extract shared account orchestration into models/user/shared for v1+v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pull the business logic out of the v1 current-user account/settings handlers into reusable functions so both v1 and the upcoming v2 handlers call one implementation. No behavior change — the v1 handlers keep their HTTP-layer quirks (input binding, validation, error mapping); only orchestration moves. Homes are forced by the import graph: - shared.GetAuthProviderName (new pkg/routes/api/shared, above openid+user so it can combine both without a cycle; routes-only helper) - user.ChangeUserEmail (CheckUserCredentials + UpdateEmail, both in user) - models.ChangeUserPassword (needs models.DeleteAllUserSessions; user can't import models) - models.UpdateUserGeneralSettings / UpdateUserAvatarProvider (need avatar.FlushAllCaches; user can't import avatar) The general settings get a single shared wire struct, models.UserGeneralSettings (tagged for both swaggo/govalidator and Huma): it is the update request body and the nested settings on GET /user for v1 (replacing v1's UserSettings) and v2. ExtraSettingsLinks is readOnly — populated from the user on read, ignored on write. A dedicated struct is required because user.User's settings fields are json:"-" so they don't leak when it is embedded in other responses. --- .golangci.yml | 4 + pkg/models/user_settings.go | 128 ++++++++++++++++++++++ pkg/routes/api/shared/auth_provider.go | 54 +++++++++ pkg/routes/api/v1/user_settings.go | 70 +----------- pkg/routes/api/v1/user_show.go | 58 ++-------- pkg/routes/api/v1/user_update_email.go | 12 +- pkg/routes/api/v1/user_update_password.go | 18 +-- pkg/user/update_email.go | 11 ++ 8 files changed, 212 insertions(+), 143 deletions(-) create mode 100644 pkg/models/user_settings.go create mode 100644 pkg/routes/api/shared/auth_provider.go diff --git a/.golangci.yml b/.golangci.yml index 6f1a759f2..552e13cb7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -145,6 +145,10 @@ linters: - revive path: pkg/utils/* text: 'var-naming: avoid meaningless package names' + - linters: + - revive + path: pkg/routes/api/shared/* + text: 'var-naming: avoid meaningless package names' - linters: - revive text: 'var-naming: avoid package names that conflict with Go standard library package names' diff --git a/pkg/models/user_settings.go b/pkg/models/user_settings.go new file mode 100644 index 000000000..cba87cdb5 --- /dev/null +++ b/pkg/models/user_settings.go @@ -0,0 +1,128 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/api/pkg/modules/avatar" + "code.vikunja.io/api/pkg/user" + + "xorm.io/xorm" +) + +// UserGeneralSettings is the single user-settings wire struct shared by v1 and +// v2 — both the update request body and the nested settings on GET /user. A +// dedicated struct (not user.User) is required: user.User's settings fields are +// json:"-" so they don't leak when it is embedded in other responses +// (assignees, created_by, members …). +type UserGeneralSettings struct { + Name string `json:"name" doc:"The full name of the user."` + EmailRemindersEnabled bool `json:"email_reminders_enabled" doc:"If enabled, sends email reminders of tasks to the user."` + DiscoverableByName bool `json:"discoverable_by_name" doc:"If true, this user can be found by their name or parts of it when searching."` + DiscoverableByEmail bool `json:"discoverable_by_email" doc:"If true, the user can be found when searching for their exact email."` + OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled" doc:"If enabled, the user gets an email for their overdue tasks each morning."` + OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required" doc:"The time the daily overdue-tasks summary is sent, as HH:MM."` + DefaultProjectID int64 `json:"default_project_id" doc:"Project a task is filed under when created without an explicit project."` + WeekStart int `json:"week_start" valid:"range(0|6)" minimum:"0" maximum:"6" doc:"The day the week starts on: 0=sunday, 1=monday, … 6=saturday."` + Language string `json:"language" doc:"The user's language."` + Timezone string `json:"timezone" doc:"The user's time zone, used to send task reminders in their local time."` + FrontendSettings any `json:"frontend_settings" doc:"Arbitrary settings used only by the frontend. Any JSON value; stored and returned verbatim."` + // Server/OpenID-provided; populated on read, ignored on write. + ExtraSettingsLinks map[string]any `json:"extra_settings_links" readOnly:"true" doc:"Additional settings links provided by the OpenID provider. Server-controlled."` +} + +// NewUserGeneralSettings projects a user's stored settings into the shared wire +// struct for GET /user. Used by both the v1 and v2 user-show handlers. +func NewUserGeneralSettings(u *user.User) *UserGeneralSettings { + return &UserGeneralSettings{ + Name: u.Name, + EmailRemindersEnabled: u.EmailRemindersEnabled, + DiscoverableByName: u.DiscoverableByName, + DiscoverableByEmail: u.DiscoverableByEmail, + OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, + OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, + DefaultProjectID: u.DefaultProjectID, + WeekStart: u.WeekStart, + Language: u.Language, + Timezone: u.Timezone, + FrontendSettings: u.FrontendSettings, + ExtraSettingsLinks: u.ExtraSettingsLinks, + } +} + +// ChangeUserPassword verifies the old password, sets the new one, and +// invalidates all of the user's sessions. Lives here (not in pkg/user) because +// it needs DeleteAllUserSessions, which pkg/user cannot import. +func ChangeUserPassword(s *xorm.Session, u *user.User, oldPassword, newPassword string) error { + if oldPassword == "" { + return user.ErrEmptyOldPassword{} + } + + if _, err := user.CheckUserCredentials(s, &user.Login{Username: u.Username, Password: oldPassword}); err != nil { + return err + } + + if err := user.UpdateUserPassword(s, u, newPassword); err != nil { + return err + } + + return DeleteAllUserSessions(s, u.ID) +} + +// UpdateUserGeneralSettings copies the general settings onto the user, persists +// them, and flushes the avatar cache when an initials avatar's name changed. +// Lives here (not in pkg/user) because the avatar flush needs pkg/modules/avatar, +// which pkg/user cannot import. +func UpdateUserGeneralSettings(s *xorm.Session, u *user.User, settings *UserGeneralSettings) error { + invalidateAvatar := u.AvatarProvider == "initials" && u.Name != settings.Name + + u.Name = settings.Name + u.EmailRemindersEnabled = settings.EmailRemindersEnabled + u.DiscoverableByEmail = settings.DiscoverableByEmail + u.DiscoverableByName = settings.DiscoverableByName + u.OverdueTasksRemindersEnabled = settings.OverdueTasksRemindersEnabled + u.DefaultProjectID = settings.DefaultProjectID + u.WeekStart = settings.WeekStart + u.Language = settings.Language + u.Timezone = settings.Timezone + u.OverdueTasksRemindersTime = settings.OverdueTasksRemindersTime + u.FrontendSettings = settings.FrontendSettings + + if _, err := user.UpdateUser(s, u, true); err != nil { + return err + } + + if invalidateAvatar { + avatar.FlushAllCaches(u) + } + return nil +} + +// UpdateUserAvatarProvider sets the user's avatar provider, persists it, and +// flushes the avatar cache when the provider changes (or is set to initials). +func UpdateUserAvatarProvider(s *xorm.Session, u *user.User, provider string) error { + oldProvider := u.AvatarProvider + u.AvatarProvider = provider + + if _, err := user.UpdateUser(s, u, false); err != nil { + return err + } + + if u.AvatarProvider == "initials" || oldProvider != u.AvatarProvider { + avatar.FlushAllCaches(u) + } + return nil +} diff --git a/pkg/routes/api/shared/auth_provider.go b/pkg/routes/api/shared/auth_provider.go new file mode 100644 index 000000000..042a5567d --- /dev/null +++ b/pkg/routes/api/shared/auth_provider.go @@ -0,0 +1,54 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +// Package shared holds helpers used by both the v1 and v2 route packages. It +// sits above the auth/user modules in the import graph, so it can combine them +// without creating a cycle. +package shared + +import ( + "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/user" +) + +// GetAuthProviderName resolves the human-readable name of the source a user +// authenticated with: "local"/"ldap" for those issuers, otherwise the +// configured OpenID provider whose issuer URL matches the user's. Returns "" +// when no provider matches. +func GetAuthProviderName(u *user.User) (string, error) { + switch u.Issuer { + case user.IssuerLocal: + return "local", nil + case user.IssuerLDAP: + return "ldap", nil + } + + providers, err := openid.GetAllProviders() + if err != nil { + return "", err + } + for _, provider := range providers { + issuerURL, err := provider.Issuer() + if err != nil { + return "", err + } + if issuerURL == u.Issuer { + return provider.Name, nil + } + } + + return "", nil +} diff --git a/pkg/routes/api/v1/user_settings.go b/pkg/routes/api/v1/user_settings.go index 2efa9c0f0..049330411 100644 --- a/pkg/routes/api/v1/user_settings.go +++ b/pkg/routes/api/v1/user_settings.go @@ -26,7 +26,6 @@ import ( "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/models" - "code.vikunja.io/api/pkg/modules/avatar" user2 "code.vikunja.io/api/pkg/user" ) @@ -36,35 +35,6 @@ type UserAvatarProvider struct { AvatarProvider string `json:"avatar_provider"` } -// UserSettings holds all user settings -type UserSettings struct { - // The new name of the current user. - Name string `json:"name"` - // If enabled, sends email reminders of tasks to the user. - EmailRemindersEnabled bool `json:"email_reminders_enabled"` - // If true, this user can be found by their name or parts of it when searching for it. - DiscoverableByName bool `json:"discoverable_by_name"` - // If true, the user can be found when searching for their exact email. - DiscoverableByEmail bool `json:"discoverable_by_email"` - // If enabled, the user will get an email for their overdue tasks each morning. - OverdueTasksRemindersEnabled bool `json:"overdue_tasks_reminders_enabled"` - // The time when the daily summary of overdue tasks will be sent via email. - OverdueTasksRemindersTime string `json:"overdue_tasks_reminders_time" valid:"time,required"` - // If a task is created without a specified project this value should be used. Applies - // to tasks made directly in API and from clients. - DefaultProjectID int64 `json:"default_project_id"` - // The day when the week starts for this user. 0 = sunday, 1 = monday, etc. - WeekStart int `json:"week_start" valid:"range(0|6)"` - // The user's language - Language string `json:"language"` - // The user's time zone. Used to send task reminders in the time zone of the user. - Timezone string `json:"timezone"` - // Additional settings only used by the frontend - FrontendSettings interface{} `json:"frontend_settings"` - // Additional settings links as provided by openid - ExtraSettingsLinks map[string]any `json:"extra_settings_links"` -} - // GetUserAvatarProvider returns the currently set user avatar // @Summary Return user avatar setting // @Description Returns the current user's avatar setting. @@ -135,29 +105,16 @@ func ChangeUserAvatarProvider(c *echo.Context) error { return err } - oldProvider := user.AvatarProvider - - user.AvatarProvider = uap.AvatarProvider - - _, err = user2.UpdateUser(s, user, false) - if err != nil { + if err := models.UpdateUserAvatarProvider(s, user, uap.AvatarProvider); err != nil { _ = s.Rollback() return err } - if user.AvatarProvider == "initials" { - avatar.FlushAllCaches(user) - } - if err := s.Commit(); err != nil { _ = s.Rollback() return err } - if oldProvider != user.AvatarProvider { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "Avatar was changed successfully."}) } @@ -167,13 +124,13 @@ func ChangeUserAvatarProvider(c *echo.Context) error { // @Accept json // @Produce json // @Security JWTKeyAuth -// @Param avatar body UserSettings true "The updated user settings" +// @Param avatar body models.UserGeneralSettings true "The updated user settings" // @Success 200 {object} models.Message // @Failure 400 {object} web.HTTPError "Something's invalid." // @Failure 500 {object} models.Message "Internal server error." // @Router /user/settings/general [post] func UpdateGeneralUserSettings(c *echo.Context) error { - us := &UserSettings{} + us := &models.UserGeneralSettings{} err := c.Bind(us) if err != nil { var he *echo.HTTPError @@ -202,22 +159,7 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - invalidateAvatar := user.AvatarProvider == "initials" && user.Name != us.Name - - user.Name = us.Name - user.EmailRemindersEnabled = us.EmailRemindersEnabled - user.DiscoverableByEmail = us.DiscoverableByEmail - user.DiscoverableByName = us.DiscoverableByName - user.OverdueTasksRemindersEnabled = us.OverdueTasksRemindersEnabled - user.DefaultProjectID = us.DefaultProjectID - user.WeekStart = us.WeekStart - user.Language = us.Language - user.Timezone = us.Timezone - user.OverdueTasksRemindersTime = us.OverdueTasksRemindersTime - user.FrontendSettings = us.FrontendSettings - - _, err = user2.UpdateUser(s, user, true) - if err != nil { + if err := models.UpdateUserGeneralSettings(s, user, us); err != nil { _ = s.Rollback() return err } @@ -227,10 +169,6 @@ func UpdateGeneralUserSettings(c *echo.Context) error { return err } - if invalidateAvatar { - avatar.FlushAllCaches(user) - } - return c.JSON(http.StatusOK, &models.Message{Message: "The settings were updated successfully."}) } diff --git a/pkg/routes/api/v1/user_show.go b/pkg/routes/api/v1/user_show.go index d5a391267..655b0fb5c 100644 --- a/pkg/routes/api/v1/user_show.go +++ b/pkg/routes/api/v1/user_show.go @@ -20,7 +20,7 @@ import ( "net/http" "time" - "code.vikunja.io/api/pkg/modules/auth/openid" + "code.vikunja.io/api/pkg/routes/api/shared" "code.vikunja.io/api/pkg/user" @@ -34,11 +34,11 @@ import ( type UserWithSettings struct { user.User - Settings *UserSettings `json:"settings"` - DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` - IsLocalUser bool `json:"is_local_user"` - AuthProvider string `json:"auth_provider"` - IsAdmin bool `json:"is_admin"` + Settings *models.UserGeneralSettings `json:"settings"` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at"` + IsLocalUser bool `json:"is_local_user"` + AuthProvider string `json:"auth_provider"` + IsAdmin bool `json:"is_admin"` } // UserShow gets all information about the current user @@ -67,57 +67,17 @@ func UserShow(c *echo.Context) error { } us := &UserWithSettings{ - User: *u, - Settings: &UserSettings{ - Name: u.Name, - EmailRemindersEnabled: u.EmailRemindersEnabled, - DiscoverableByName: u.DiscoverableByName, - DiscoverableByEmail: u.DiscoverableByEmail, - OverdueTasksRemindersEnabled: u.OverdueTasksRemindersEnabled, - DefaultProjectID: u.DefaultProjectID, - WeekStart: u.WeekStart, - Language: u.Language, - Timezone: u.Timezone, - OverdueTasksRemindersTime: u.OverdueTasksRemindersTime, - FrontendSettings: u.FrontendSettings, - ExtraSettingsLinks: u.ExtraSettingsLinks, - }, + User: *u, + Settings: models.NewUserGeneralSettings(u), DeletionScheduledAt: u.DeletionScheduledAt, IsLocalUser: u.Issuer == user.IssuerLocal, IsAdmin: u.IsAdmin, } - us.AuthProvider, err = getAuthProviderName(u) + us.AuthProvider, err = shared.GetAuthProviderName(u) if err != nil { return err } return c.JSON(http.StatusOK, us) } - -func getAuthProviderName(u *user.User) (name string, err error) { - if u.Issuer == user.IssuerLocal { - return "local", nil - } - - if u.Issuer == user.IssuerLDAP { - return "ldap", nil - } - - providers, err := openid.GetAllProviders() - if err != nil { - return "", err - } - - for _, provider := range providers { - issuerURL, err := provider.Issuer() - if err != nil { - return "", err - } - if issuerURL == u.Issuer { - return provider.Name, nil - } - } - - return -} diff --git a/pkg/routes/api/v1/user_update_email.go b/pkg/routes/api/v1/user_update_email.go index ea1077075..e73ba6f89 100644 --- a/pkg/routes/api/v1/user_update_email.go +++ b/pkg/routes/api/v1/user_update_email.go @@ -62,17 +62,7 @@ func UpdateUserEmail(c *echo.Context) (err error) { s := db.NewSession() defer s.Close() - emailUpdate.User, err = user.CheckUserCredentials(s, &user.Login{ - Username: emailUpdate.User.Username, - Password: emailUpdate.Password, - }) - if err != nil { - _ = s.Rollback() - return err - } - - err = user.UpdateEmail(s, emailUpdate) - if err != nil { + if err := user.ChangeUserEmail(s, emailUpdate.User, emailUpdate.Password, emailUpdate.NewEmail); err != nil { _ = s.Rollback() return err } diff --git a/pkg/routes/api/v1/user_update_password.go b/pkg/routes/api/v1/user_update_password.go index 0172a21ec..52941a48a 100644 --- a/pkg/routes/api/v1/user_update_password.go +++ b/pkg/routes/api/v1/user_update_password.go @@ -63,26 +63,10 @@ func UserChangePassword(c *echo.Context) error { return err } - if newPW.OldPassword == "" { - return user.ErrEmptyOldPassword{} - } - s := db.NewSession() defer s.Close() - // Check the current password - if _, err = user.CheckUserCredentials(s, &user.Login{Username: doer.Username, Password: newPW.OldPassword}); err != nil { - _ = s.Rollback() - return err - } - - // Update the password - if err = user.UpdateUserPassword(s, doer, newPW.NewPassword); err != nil { - _ = s.Rollback() - return err - } - - if err := models.DeleteAllUserSessions(s, doer.ID); err != nil { + if err := models.ChangeUserPassword(s, doer, newPW.OldPassword, newPW.NewPassword); err != nil { _ = s.Rollback() return err } diff --git a/pkg/user/update_email.go b/pkg/user/update_email.go index 73e104682..b721ba518 100644 --- a/pkg/user/update_email.go +++ b/pkg/user/update_email.go @@ -31,6 +31,17 @@ type EmailUpdate struct { Password string `json:"password"` } +// ChangeUserEmail verifies the user's password, then sets a new email address +// (kicking off confirmation when the mailer is enabled). Shared by the v1 and +// v2 email-update handlers; only HTTP input binding stays in the handlers. +func ChangeUserEmail(s *xorm.Session, u *User, password, newEmail string) error { + verified, err := CheckUserCredentials(s, &Login{Username: u.Username, Password: password}) + if err != nil { + return err + } + return UpdateEmail(s, &EmailUpdate{User: verified, NewEmail: newEmail}) +} + // UpdateEmail lets a user update their email address func UpdateEmail(s *xorm.Session, update *EmailUpdate) (err error) { From 28af57bc9362a78b2549dec9267d89fca1de1c6e Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 10 Jun 2026 22:16:57 +0200 Subject: [PATCH 43/45] feat(api/v2): add user account/settings on /api/v2 Port the current-user account and settings endpoints from /api/v1 to the Huma-backed /api/v2, calling the shared orchestration extracted into models/user/openid: - GET /user current user + settings + computed auth_provider/is_local_user/is_admin - POST /user/password change password (200, creates nothing) - PUT /user/settings/email update email (kicks off confirmation) - PUT /user/settings/general update general settings - GET /user/settings/avatar/provider get avatar provider - PUT /user/settings/avatar/provider set avatar provider - GET /user/timezones list available time zones These are current-user-scoped custom handlers (no per-resource Can*): each pulls the authed user from the request context and operates on it. The avatar provider get/set live on /user/settings/avatar/provider because v2 already maps /user/settings/avatar to the binary avatar upload (PUT). --- pkg/routes/api/v2/user_settings.go | 334 ++++++++++++++++++++++++ pkg/webtests/huma_user_settings_test.go | 195 ++++++++++++++ 2 files changed, 529 insertions(+) create mode 100644 pkg/routes/api/v2/user_settings.go create mode 100644 pkg/webtests/huma_user_settings_test.go diff --git a/pkg/routes/api/v2/user_settings.go b/pkg/routes/api/v2/user_settings.go new file mode 100644 index 000000000..a1f5bbee4 --- /dev/null +++ b/pkg/routes/api/v2/user_settings.go @@ -0,0 +1,334 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package apiv2 + +import ( + "context" + "net/http" + "time" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/routes/api/shared" + "code.vikunja.io/api/pkg/user" + + "github.com/danielgtaylor/huma/v2" + "github.com/tkuchiki/go-timezone" +) + +// userInfoBody is the GET /user response: the public user fields plus the +// computed account facts v1 returned alongside the user object. +type userInfoBody struct { + user.User + Settings *models.UserGeneralSettings `json:"settings" readOnly:"true" doc:"The current user's settings."` + DeletionScheduledAt time.Time `json:"deletion_scheduled_at" readOnly:"true" doc:"When the account is scheduled for deletion, if a deletion was requested."` + IsLocalUser bool `json:"is_local_user" readOnly:"true" doc:"True if the user authenticates locally (not via LDAP or OpenID)."` + AuthProvider string `json:"auth_provider" readOnly:"true" doc:"The name of the source the user authenticated with: 'local', 'ldap', or the configured OpenID provider name."` + IsAdmin bool `json:"is_admin" readOnly:"true" doc:"True if the user is an instance administrator."` +} + +// userAvatarProviderBody is the get/set body for the user's avatar provider. +type userAvatarProviderBody struct { + AvatarProvider string `json:"avatar_provider" doc:"The avatar provider. One of: gravatar (uses the user email), upload, initials, marble (random per user), ldap (synced from LDAP), openid (synced from OpenID), default."` +} + +type userActionMessageBody struct { + Message string `json:"message" readOnly:"true" doc:"A confirmation message."` +} + +// RegisterUserSettingsRoutes wires the current-user account & settings +// endpoints onto the Huma API. These are not CRUDable resources: each operates +// on the authenticated user pulled from the request context. +func RegisterUserSettingsRoutes(api huma.API) { + tags := []string{"user"} + + Register(api, huma.Operation{ + OperationID: "user-show", + Summary: "Get the current user", + Description: "Returns the authenticated user together with their settings and computed account facts (auth_provider, is_local_user, is_admin, deletion_scheduled_at).", + Method: http.MethodGet, + Path: "/user", + Tags: tags, + }, userShow) + + Register(api, huma.Operation{ + OperationID: "user-change-password", + Summary: "Change the current user's password", + Description: "Changes the authenticated user's password after verifying the old one. All of the user's existing sessions are invalidated.", + Method: http.MethodPost, + Path: "/user/password", + // Changes a password, it creates nothing — keep 200 over the wrapper's POST→201. + DefaultStatus: http.StatusOK, + Tags: tags, + }, userChangePassword) + + Register(api, huma.Operation{ + OperationID: "user-update-email", + Summary: "Update the current user's email address", + Description: "Sets a new email address for the authenticated user after verifying their password. If the mailer is enabled the change is pending until the user confirms it via a link sent to the new address; otherwise it takes effect immediately.", + Method: http.MethodPut, + Path: "/user/settings/email", + Tags: tags, + }, userUpdateEmail) + + Register(api, huma.Operation{ + OperationID: "user-update-settings", + Summary: "Update the current user's general settings", + Description: "Replaces the authenticated user's general settings (name, reminders, discoverability, default project, week start, language, timezone, frontend settings).", + Method: http.MethodPut, + Path: "/user/settings/general", + Tags: tags, + }, userUpdateSettings) + + // Path differs from v1's /user/settings/avatar: on v2 that path is the + // binary avatar upload (PUT), so the provider get/set live on a sub-path. + Register(api, huma.Operation{ + OperationID: "user-get-avatar-provider", + Summary: "Get the current user's avatar provider", + Description: "Returns the avatar provider configured for the authenticated user.", + Method: http.MethodGet, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userGetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-set-avatar-provider", + Summary: "Set the current user's avatar provider", + Description: "Changes the avatar provider for the authenticated user. Valid values: gravatar, upload, initials, marble, ldap, openid, default.", + Method: http.MethodPut, + Path: "/user/settings/avatar/provider", + Tags: tags, + }, userSetAvatarProvider) + + Register(api, huma.Operation{ + OperationID: "user-timezones", + Summary: "List available time zones", + Description: "Returns every time zone this Vikunja instance can handle. The list depends on the host system and is unsorted; sort it client-side.", + Method: http.MethodGet, + Path: "/user/timezones", + Tags: tags, + }, userTimezones) +} + +func init() { AddRouteRegistrar(RegisterUserSettingsRoutes) } + +func userShow(ctx context.Context, _ *struct{}) (*singleBody[userInfoBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + + s := db.NewSession() + defer s.Close() + + u, err := models.GetUserOrLinkShareUser(s, a) + if err != nil { + return nil, translateDomainError(err) + } + + info := &userInfoBody{ + User: *u, + Settings: models.NewUserGeneralSettings(u), + DeletionScheduledAt: u.DeletionScheduledAt, + IsLocalUser: u.Issuer == user.IssuerLocal, + IsAdmin: u.IsAdmin, + } + + // nolint:contextcheck // openid.GetAllProviders/Issuer (called via shared) take + // no context; threading one would change those signatures across both APIs. + info.AuthProvider, err = shared.GetAuthProviderName(u) + if err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userInfoBody]{Body: info}, nil +} + +func userChangePassword(ctx context.Context, in *struct { + Body struct { + OldPassword string `json:"old_password" doc:"The current password, for confirmation."` + NewPassword string `json:"new_password" valid:"bcrypt_password" minLength:"8" maxLength:"72" doc:"The new password. Max 72 bytes (a bcrypt limit), which may be fewer than 72 characters."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := models.ChangeUserPassword(s, doer, in.Body.OldPassword, in.Body.NewPassword); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The password was updated successfully."}}, nil +} + +func userUpdateEmail(ctx context.Context, in *struct { + Body struct { + NewEmail string `json:"new_email" valid:"email,length(0|250),required" maxLength:"250" doc:"The new email address."` + Password string `json:"password" doc:"The current password, for confirmation."` + } +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + if err := user.ChangeUserEmail(s, doer, in.Body.Password, in.Body.NewEmail); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "We sent you email with a link to confirm your email address."}}, nil +} + +func userUpdateSettings(ctx context.Context, in *struct { + Body models.UserGeneralSettings +}) (*singleBody[userActionMessageBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserGeneralSettings(s, u, &in.Body); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userActionMessageBody]{Body: &userActionMessageBody{Message: "The settings were updated successfully."}}, nil +} + +func userGetAvatarProvider(ctx context.Context, _ *struct{}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +func userSetAvatarProvider(ctx context.Context, in *struct { + Body userAvatarProviderBody +}) (*singleBody[userAvatarProviderBody], error) { + a, err := authFromCtx(ctx) + if err != nil { + return nil, err + } + doer, err := user.GetFromAuth(a) + if err != nil { + return nil, translateDomainError(err) + } + + s := db.NewSession() + defer s.Close() + + u, err := user.GetUserWithEmail(s, &user.User{ID: doer.ID}) + if err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := models.UpdateUserAvatarProvider(s, u, in.Body.AvatarProvider); err != nil { + _ = s.Rollback() + return nil, translateDomainError(err) + } + + if err := s.Commit(); err != nil { + return nil, translateDomainError(err) + } + + return &singleBody[userAvatarProviderBody]{Body: &userAvatarProviderBody{AvatarProvider: u.AvatarProvider}}, nil +} + +type timezonesBody struct { + Body []string +} + +func userTimezones(ctx context.Context, _ *struct{}) (*timezonesBody, error) { + if _, err := authFromCtx(ctx); err != nil { + return nil, err + } + + timezoneMap := make(map[string]bool) // de-dupe across the per-abbreviation groups + for _, group := range timezone.New().Timezones() { + for _, t := range group { + timezoneMap[t] = true + } + } + + ts := make([]string, 0, len(timezoneMap)) + for t := range timezoneMap { + ts = append(ts, t) + } + + return &timezonesBody{Body: ts}, nil +} diff --git a/pkg/webtests/huma_user_settings_test.go b/pkg/webtests/huma_user_settings_test.go new file mode 100644 index 000000000..24e7469f3 --- /dev/null +++ b/pkg/webtests/huma_user_settings_test.go @@ -0,0 +1,195 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package webtests + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All subtests in a Test* func share one env: setupTestEnv rotates the JWT +// secret per call, so a token must be issued from the same env it's used +// against. Where a subtest mutates the user, later subtests account for it. + +func TestHumaUserShow(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + body := rec.Body.String() + assert.Contains(t, body, `"id":1`) + assert.Contains(t, body, `"username":"user1"`) + // Like v1, /user does not disclose the email (GetUserByID strips it); the + // json:"email,omitempty" tag then drops the field entirely. + assert.NotContains(t, body, `"email":""`) + // Computed account facts v1 returned alongside the user object. + assert.Contains(t, body, `"auth_provider":"local"`) + assert.Contains(t, body, `"is_local_user":true`) + assert.Contains(t, body, `"is_admin":false`) + // The nested settings use the shared models.UserGeneralSettings shape. + assert.Contains(t, body, `"settings":`) + assert.Contains(t, body, `"frontend_settings":`) + assert.Contains(t, body, `"extra_settings_links":`) + + t.Run("Unauthenticated", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", "", "") + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) +} + +func TestHumaUserChangePassword(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong old password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"invalid","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Empty old password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"","new_password":"123456789"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("New password too short", func(t *testing.T) { + // v2 maps govalidator failures (bcrypt_password) to 422, not v1's 412. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"1234567"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal - run last, it changes the password", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/user/password", + `{"old_password":"12345678","new_password":"123456789"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The password was updated successfully.") + }) +} + +func TestHumaUserUpdateEmail(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Wrong password", func(t *testing.T) { + // CheckUserCredentials → ErrWrongUsernameOrPassword (403). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"invalid"}`, token, "") + assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Missing new email", func(t *testing.T) { + // new_email carries valid:"...,required"; v2 maps the failure to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"password":"12345678"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/email", + `{"new_email":"new@example.com","password":"12345678"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "confirm your email address") + }) +} + +func TestHumaUserUpdateSettings(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Normal", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"name":"New Name","week_start":1,"overdue_tasks_reminders_time":"10:00","timezone":"Europe/Berlin"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "The settings were updated successfully.") + + // The change is observable through user-show. + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + assert.Contains(t, show.Body.String(), `"name":"New Name"`) + }) + t.Run("Frontend settings round-trip as arbitrary JSON", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"overdue_tasks_reminders_time":"09:00","frontend_settings":{"color_schema":"dark","nested":{"a":1}}}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + show := humaRequest(t, e, http.MethodGet, "/api/v2/user", "", token, "") + require.Equal(t, http.StatusOK, show.Code) + var resp struct { + Settings struct { + FrontendSettings map[string]any `json:"frontend_settings"` + } `json:"settings"` + } + require.NoError(t, json.Unmarshal(show.Body.Bytes(), &resp)) + assert.Equal(t, "dark", resp.Settings.FrontendSettings["color_schema"]) + }) + t.Run("Invalid week_start", func(t *testing.T) { + // week_start carries valid:"range(0|6)"; out of range maps to 422. + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/general", + `{"week_start":9,"overdue_tasks_reminders_time":"09:00"}`, token, "") + assert.Equal(t, http.StatusUnprocessableEntity, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserAvatarProvider(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("Get", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":`) + }) + t.Run("Set then get reflects the change", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"initials"}`, token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"avatar_provider":"initials"`) + + get := humaRequest(t, e, http.MethodGet, "/api/v2/user/settings/avatar/provider", "", token, "") + require.Equal(t, http.StatusOK, get.Code) + assert.Contains(t, get.Body.String(), `"avatar_provider":"initials"`) + }) + t.Run("Invalid provider", func(t *testing.T) { + // UpdateUser rejects unknown providers with ErrInvalidAvatarProvider (412). + rec := humaRequest(t, e, http.MethodPut, "/api/v2/user/settings/avatar/provider", + `{"avatar_provider":"nonsense"}`, token, "") + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, "body: %s", rec.Body.String()) + }) +} + +func TestHumaUserTimezones(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/user/timezones", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + var zones []string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &zones)) + assert.NotEmpty(t, zones) + assert.Contains(t, zones, "Europe/Berlin") +} From 05b10e34d89967bc91d455074084b1483e7017f6 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Thu, 11 Jun 2026 07:42:32 +0000 Subject: [PATCH 44/45] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 100 ++++++++++++++++++--------------------- pkg/swagger/swagger.json | 100 ++++++++++++++++++--------------------- pkg/swagger/swagger.yaml | 81 +++++++++++++------------------ 3 files changed, 122 insertions(+), 159 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 874d9ec09..7921a5fe3 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -7848,7 +7848,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -10630,6 +10630,49 @@ const docTemplate = `{ } } }, + "models.UserGeneralSettings": { + "type": "object", + "properties": { + "default_project_id": { + "type": "integer" + }, + "discoverable_by_email": { + "type": "boolean" + }, + "discoverable_by_name": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "extra_settings_links": { + "description": "Server/OpenID-provided; populated on read, ignored on write.", + "type": "object", + "additionalProperties": {} + }, + "frontend_settings": {}, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "overdue_tasks_reminders_enabled": { + "type": "boolean" + }, + "overdue_tasks_reminders_time": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "week_start": { + "type": "integer", + "maximum": 6, + "minimum": 0 + } + } + }, "models.UserWithPermission": { "type": "object", "properties": { @@ -11027,59 +11070,6 @@ const docTemplate = `{ } } }, - "v1.UserSettings": { - "type": "object", - "properties": { - "default_project_id": { - "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.", - "type": "integer" - }, - "discoverable_by_email": { - "description": "If true, the user can be found when searching for their exact email.", - "type": "boolean" - }, - "discoverable_by_name": { - "description": "If true, this user can be found by their name or parts of it when searching for it.", - "type": "boolean" - }, - "email_reminders_enabled": { - "description": "If enabled, sends email reminders of tasks to the user.", - "type": "boolean" - }, - "extra_settings_links": { - "description": "Additional settings links as provided by openid", - "type": "object", - "additionalProperties": {} - }, - "frontend_settings": { - "description": "Additional settings only used by the frontend" - }, - "language": { - "description": "The user's language", - "type": "string" - }, - "name": { - "description": "The new name of the current user.", - "type": "string" - }, - "overdue_tasks_reminders_enabled": { - "description": "If enabled, the user will get an email for their overdue tasks each morning.", - "type": "boolean" - }, - "overdue_tasks_reminders_time": { - "description": "The time when the daily summary of overdue tasks will be sent via email.", - "type": "string" - }, - "timezone": { - "description": "The user's time zone. Used to send task reminders in the time zone of the user.", - "type": "string" - }, - "week_start": { - "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.", - "type": "integer" - } - } - }, "v1.UserWithSettings": { "type": "object", "properties": { @@ -11117,7 +11107,7 @@ const docTemplate = `{ "type": "string" }, "settings": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 1d9d15f49..fc04db4bf 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -7840,7 +7840,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" } } ], @@ -10622,6 +10622,49 @@ } } }, + "models.UserGeneralSettings": { + "type": "object", + "properties": { + "default_project_id": { + "type": "integer" + }, + "discoverable_by_email": { + "type": "boolean" + }, + "discoverable_by_name": { + "type": "boolean" + }, + "email_reminders_enabled": { + "type": "boolean" + }, + "extra_settings_links": { + "description": "Server/OpenID-provided; populated on read, ignored on write.", + "type": "object", + "additionalProperties": {} + }, + "frontend_settings": {}, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "overdue_tasks_reminders_enabled": { + "type": "boolean" + }, + "overdue_tasks_reminders_time": { + "type": "string" + }, + "timezone": { + "type": "string" + }, + "week_start": { + "type": "integer", + "maximum": 6, + "minimum": 0 + } + } + }, "models.UserWithPermission": { "type": "object", "properties": { @@ -11019,59 +11062,6 @@ } } }, - "v1.UserSettings": { - "type": "object", - "properties": { - "default_project_id": { - "description": "If a task is created without a specified project this value should be used. Applies\nto tasks made directly in API and from clients.", - "type": "integer" - }, - "discoverable_by_email": { - "description": "If true, the user can be found when searching for their exact email.", - "type": "boolean" - }, - "discoverable_by_name": { - "description": "If true, this user can be found by their name or parts of it when searching for it.", - "type": "boolean" - }, - "email_reminders_enabled": { - "description": "If enabled, sends email reminders of tasks to the user.", - "type": "boolean" - }, - "extra_settings_links": { - "description": "Additional settings links as provided by openid", - "type": "object", - "additionalProperties": {} - }, - "frontend_settings": { - "description": "Additional settings only used by the frontend" - }, - "language": { - "description": "The user's language", - "type": "string" - }, - "name": { - "description": "The new name of the current user.", - "type": "string" - }, - "overdue_tasks_reminders_enabled": { - "description": "If enabled, the user will get an email for their overdue tasks each morning.", - "type": "boolean" - }, - "overdue_tasks_reminders_time": { - "description": "The time when the daily summary of overdue tasks will be sent via email.", - "type": "string" - }, - "timezone": { - "description": "The user's time zone. Used to send task reminders in the time zone of the user.", - "type": "string" - }, - "week_start": { - "description": "The day when the week starts for this user. 0 = sunday, 1 = monday, etc.", - "type": "integer" - } - } - }, "v1.UserWithSettings": { "type": "object", "properties": { @@ -11109,7 +11099,7 @@ "type": "string" }, "settings": { - "$ref": "#/definitions/v1.UserSettings" + "$ref": "#/definitions/models.UserGeneralSettings" }, "updated": { "description": "A timestamp when this task was last updated. You cannot change this value.", diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 6bc114729..0482c4e30 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1333,6 +1333,36 @@ definitions: this value. type: string type: object + models.UserGeneralSettings: + properties: + default_project_id: + type: integer + discoverable_by_email: + type: boolean + discoverable_by_name: + type: boolean + email_reminders_enabled: + type: boolean + extra_settings_links: + additionalProperties: {} + description: Server/OpenID-provided; populated on read, ignored on write. + type: object + frontend_settings: {} + language: + type: string + name: + type: string + overdue_tasks_reminders_enabled: + type: boolean + overdue_tasks_reminders_time: + type: string + timezone: + type: string + week_start: + maximum: 6 + minimum: 0 + type: integer + type: object models.UserWithPermission: properties: bot_owner_id: @@ -1640,53 +1670,6 @@ definitions: minLength: 3 type: string type: object - v1.UserSettings: - properties: - default_project_id: - description: |- - If a task is created without a specified project this value should be used. Applies - to tasks made directly in API and from clients. - type: integer - discoverable_by_email: - description: If true, the user can be found when searching for their exact - email. - type: boolean - discoverable_by_name: - description: If true, this user can be found by their name or parts of it - when searching for it. - type: boolean - email_reminders_enabled: - description: If enabled, sends email reminders of tasks to the user. - type: boolean - extra_settings_links: - additionalProperties: {} - description: Additional settings links as provided by openid - type: object - frontend_settings: - description: Additional settings only used by the frontend - language: - description: The user's language - type: string - name: - description: The new name of the current user. - type: string - overdue_tasks_reminders_enabled: - description: If enabled, the user will get an email for their overdue tasks - each morning. - type: boolean - overdue_tasks_reminders_time: - description: The time when the daily summary of overdue tasks will be sent - via email. - type: string - timezone: - description: The user's time zone. Used to send task reminders in the time - zone of the user. - type: string - week_start: - description: The day when the week starts for this user. 0 = sunday, 1 = monday, - etc. - type: integer - type: object v1.UserWithSettings: properties: auth_provider: @@ -1717,7 +1700,7 @@ definitions: description: The full name of the user. type: string settings: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' updated: description: A timestamp when this task was last updated. You cannot change this value. @@ -7199,7 +7182,7 @@ paths: name: avatar required: true schema: - $ref: '#/definitions/v1.UserSettings' + $ref: '#/definitions/models.UserGeneralSettings' produces: - application/json responses: From a88aef0e47bb33b51fb80064b8b3c3dad5a0732c Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 11 Jun 2026 09:51:41 +0200 Subject: [PATCH 45/45] fix(deps): update shell-quote to 1.8.4 --- frontend/pnpm-lock.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e790717ee..cdcc554cb 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6095,8 +6095,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} - shell-quote@1.8.1: - resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + shell-quote@1.8.4: + resolution: {integrity: sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==} + engines: {node: '>= 0.4'} shiki@3.2.1: resolution: {integrity: sha512-VML/2o1/KGYkEf/stJJ+s9Ypn7jUKQPomGLGYso4JJFMFxVDyPNsjsI3MB3KLjlMOeH44gyaPdXC6rik2WXvUQ==} @@ -12034,7 +12035,7 @@ snapshots: launch-editor@2.10.0: dependencies: picocolors: 1.1.1 - shell-quote: 1.8.1 + shell-quote: 1.8.4 leven@3.1.0: {} @@ -13325,7 +13326,7 @@ snapshots: shebang-regex@3.0.0: {} - shell-quote@1.8.1: {} + shell-quote@1.8.4: {} shiki@3.2.1: dependencies: