Merge branch 'main' into finish-subprojects

This commit is contained in:
Malcolm Smith 2026-06-27 07:58:17 -07:00 committed by GitHub
commit 3caab51dab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
98 changed files with 3618 additions and 778 deletions

3
.envrc
View File

@ -1,3 +0,0 @@
source_url "https://raw.githubusercontent.com/cachix/devenv/95f329d49a8a5289d31e0982652f7058a189bfca/direnvrc" "sha256-d+8cBpDfDBj41inrADaJt+bDWhOktwslgoP5YiGJ1v0="
use devenv

View File

@ -100,10 +100,15 @@ app.on('second-instance', (_event, argv) => {
return
}
// Focus the main window
// Reveal the main window. It may be hidden in the tray (not just minimized),
// so show() is required — focus() alone won't surface a hidden window, which
// made the app look dead when relaunched while running in the tray.
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
} else if (serverPort) {
createMainWindow()
}
// Find the deep link URL in argv
@ -236,6 +241,11 @@ function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1680,
height: 960,
// Without an explicit window icon, X11/XWayland compositors (e.g. KDE
// Plasma) fall back to a generic placeholder when WM_CLASS doesn't match
// an installed .desktop file. icon.png lives at the app root because
// build/ is electron-builder's buildResources dir and isn't packaged.
icon: path.join(__dirname, 'icon.png'),
webPreferences: {
...BASE_WEB_PREFERENCES,
preload: path.join(__dirname, 'preload.js'),
@ -543,3 +553,14 @@ app.on('window-all-closed', () => {
app.quit()
}
})
// Quit on termination signals (DE/systemd shutdown, `kill`). Without an explicit
// handler the app ignores SIGTERM because the tray and express server keep the
// event loop alive — leaving users to `kill -9`. isQuitting must be set first so
// the hide-to-tray close handler doesn't swallow the quit.
for (const signal of ['SIGINT', 'SIGTERM']) {
process.on(signal, () => {
isQuitting = true
app.quit()
})
}

View File

@ -61,9 +61,9 @@
}
},
"devDependencies": {
"electron": "40.10.4",
"electron": "40.10.5",
"electron-builder": "26.15.3",
"unzipper": "0.12.3"
"unzipper": "0.12.5"
},
"dependencies": {
"express": "5.2.1"
@ -80,7 +80,9 @@
"tmp": ">=0.2.7",
"ip-address": ">=10.1.1",
"form-data": ">=4.0.6",
"js-yaml": ">=4.2.0"
"js-yaml": ">=4.2.0",
"undici@6": "^6.27.0",
"undici@7": "^7.28.0"
}
}
}

View File

@ -13,6 +13,8 @@ overrides:
ip-address: '>=10.1.1'
form-data: '>=4.0.6'
js-yaml: '>=4.2.0'
undici@6: ^6.27.0
undici@7: ^7.28.0
importers:
@ -23,14 +25,14 @@ importers:
version: 5.2.1
devDependencies:
electron:
specifier: 40.10.4
version: 40.10.4
specifier: 40.10.5
version: 40.10.5
electron-builder:
specifier: 26.15.3
version: 26.15.3(electron-builder-squirrel-windows@24.13.3)
unzipper:
specifier: 0.12.3
version: 0.12.3
specifier: 0.12.5
version: 0.12.5
packages:
@ -535,8 +537,8 @@ packages:
electron-publish@26.15.3:
resolution: {integrity: sha512-g/2bn8YTavY4cuS5F+jOS7zmZbXXBV8KZ8yHKfJjFPoKtzBqrpCdNPxBd3tqdBwP7BVd0lGzf7Bk2s0KesWZ4Q==}
electron@40.10.4:
resolution: {integrity: sha512-ouNZrXXmdPL/wiTQ+xzXpb7B/BHg+j7XARig0SE7azFO3bjbYUd6lFjIAAiDQ02Pl/Oj7MUk+4C0hdf9yFtA1A==}
electron@40.10.5:
resolution: {integrity: sha512-VzTIvwOYXZZufT9B83GDQogR1TFqREygRYhm0LE++QhGPjvBeg+W7siOP9K5+9rHMUnRuCX4YU/0ivLekN/UZQ==}
engines: {node: '>= 22.12.0'}
hasBin: true
@ -653,10 +655,6 @@ packages:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
fs-extra@11.2.0:
resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==}
engines: {node: '>=14.14'}
fs-extra@11.3.1:
resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
engines: {node: '>=14.14'}
@ -860,9 +858,6 @@ packages:
jsonfile@4.0.0:
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
jsonfile@6.2.0:
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
@ -1351,12 +1346,12 @@ packages:
undici-types@7.16.0:
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
undici@6.26.0:
resolution: {integrity: sha512-4yqz8a3n5HmGTlsbADNtr/dJlhkh/55Rq798G6ibiULcXbDtaLpTl1pvdqcbFfeoj3iSi52lePFM7h9H21cw/A==}
undici@6.27.0:
resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==}
engines: {node: '>=18.17'}
undici@7.27.2:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
undici@7.28.0:
resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==}
engines: {node: '>=20.18.1'}
universalify@0.1.2:
@ -1371,8 +1366,8 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
unzipper@0.12.3:
resolution: {integrity: sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==}
unzipper@0.12.5:
resolution: {integrity: sha512-tXYOi9R57Uj/2Z25SOs5RRSzq886MBQj2gY8dPL+xl/kv6s6SvByoKfAtvfVeEuhntWDgjd2o9p2lb4TVPAz0A==}
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
@ -1493,7 +1488,7 @@ snapshots:
semver: 7.8.1
sumchecker: 3.0.1
optionalDependencies:
undici: 7.27.2
undici: 7.28.0
transitivePeerDependencies:
- supports-color
@ -1794,7 +1789,7 @@ snapshots:
tar: 7.5.16
temp-file: 3.4.0
tiny-async-pool: 1.3.0
unzipper: 0.12.3
unzipper: 0.12.5
which: 5.0.0
transitivePeerDependencies:
- supports-color
@ -2184,7 +2179,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
electron@40.10.4:
electron@40.10.5:
dependencies:
'@electron-internal/extract-zip': 1.0.2
'@electron/get': 5.0.0
@ -2320,12 +2315,6 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
fs-extra@11.2.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
fs-extra@11.3.1:
dependencies:
graceful-fs: 4.2.11
@ -2563,12 +2552,6 @@ snapshots:
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
jsonfile@6.2.0:
dependencies:
universalify: 2.0.1
@ -2668,7 +2651,7 @@ snapshots:
semver: 7.8.1
tar: 7.5.16
tinyglobby: 0.2.15
undici: 6.26.0
undici: 6.27.0
which: 6.0.1
node-int64@0.4.0: {}
@ -3067,9 +3050,9 @@ snapshots:
undici-types@7.16.0: {}
undici@6.26.0: {}
undici@6.27.0: {}
undici@7.27.2:
undici@7.28.0:
optional: true
universalify@0.1.2: {}
@ -3078,11 +3061,11 @@ snapshots:
unpipe@1.0.0: {}
unzipper@0.12.3:
unzipper@0.12.5:
dependencies:
bluebird: 3.7.2
duplexer2: 0.1.4
fs-extra: 11.2.0
fs-extra: 11.3.1
graceful-fs: 4.2.11
node-int64: 0.4.0

View File

@ -3,10 +3,11 @@
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1773012232,
"lastModified": 1782492839,
"narHash": "sha256-j9wrcB4al5QhMelEghJ0Qs+RQPT+wyCcI4070NEgPLQ=",
"owner": "cachix",
"repo": "devenv",
"rev": "46a4bd0299a26ad948b71d3053174ba7b90522f7",
"rev": "3d39d0817d62069f7b18821c34a617b5141cb278",
"type": "github"
},
"original": {
@ -21,10 +22,11 @@
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1772749504,
"lastModified": 1782132010,
"narHash": "sha256-ZnAVHdVrotp80iIMm5CSR1fdxPlw7Uwmwxb+O/wsgZ8=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "08543693199362c1fddb8f52126030d0d374ba2e",
"rev": "12866ae2dddbc0ab8b329915f8072bb9c75bde89",
"type": "github"
},
"original": {
@ -37,11 +39,11 @@
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1769922788,
"narHash": "sha256-H3AfG4ObMDTkTJYkd8cz1/RbY9LatN5Mk4UF48VuSXc=",
"lastModified": 1781607440,
"narHash": "sha256-rxO+uc/KFbSJp+pgyXRuAX6QlG9hJdnt0BXpEQRXY+U=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "207d15f1a6603226e1e223dc79ac29c7846da32e",
"rev": "3e41b24abd260e8f71dbe2f5737d24122f972158",
"type": "github"
},
"original": {
@ -53,10 +55,11 @@
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1772773019,
"lastModified": 1782467914,
"narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"rev": "e73de5be04e0eff4190a1432b946d469c794e7b4",
"type": "github"
},
"original": {

View File

@ -117,15 +117,15 @@
"@types/node": "24.13.2",
"@types/sortablejs": "1.15.9",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.61.1",
"@typescript-eslint/parser": "8.61.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"@vitejs/plugin-vue": "6.0.7",
"@vue/eslint-config-typescript": "14.8.0",
"@vue/eslint-config-typescript": "14.9.0",
"@vue/test-utils": "2.4.11",
"@vue/tsconfig": "0.9.1",
"@vueuse/shared": "14.3.0",
"autoprefixer": "10.5.0",
"browserslist": "4.28.2",
"autoprefixer": "10.5.1",
"browserslist": "4.28.4",
"caniuse-lite": "1.0.30001799",
"csstype": "3.2.3",
"esbuild": "0.28.1",
@ -139,7 +139,7 @@
"postcss-easing-gradients": "3.0.1",
"postcss-html": "1.8.1",
"postcss-preset-env": "11.3.1",
"rollup": "4.62.0",
"rollup": "4.62.2",
"rollup-plugin-visualizer": "6.0.11",
"sass-embedded": "1.100.0",
"stylelint": "17.13.0",

File diff suppressed because it is too large Load Diff

View File

@ -13,14 +13,14 @@
<div class="gantt-chart-wrapper">
<GanttTimelineHeader
:timeline-data="timelineData"
:day-width-pixels="DAY_WIDTH_PIXELS"
:day-width-pixels="dayWidthPixels"
/>
<GanttVerticalGridLines
:timeline-data="timelineData"
:total-width="totalWidth"
:height="ganttRows.length * 40"
:day-width-pixels="DAY_WIDTH_PIXELS"
:day-width-pixels="dayWidthPixels"
/>
<GanttChartBody
@ -57,7 +57,7 @@
:total-width="totalWidth"
:date-from-date="dateFromDate"
:date-to-date="dateToDate"
:day-width-pixels="DAY_WIDTH_PIXELS"
:day-width-pixels="dayWidthPixels"
:is-dragging="isDragging"
:is-resizing="isResizing"
:drag-state="dragState"
@ -89,7 +89,7 @@
</template>
<script setup lang="ts">
import {computed, ref, watch, toRefs, onUnmounted} from 'vue'
import {computed, ref, watch, toRefs, nextTick, onMounted, onBeforeUnmount, onUnmounted} from 'vue'
import {useRouter} from 'vue-router'
import dayjs from 'dayjs'
import {useDayjsLanguageSync} from '@/i18n/useDayjsLanguageSync'
@ -128,7 +128,9 @@ const emit = defineEmits<{
(e: 'update:task', task: ITaskPartialWithId): void
}>()
const DAY_WIDTH_PIXELS = 30
const DAY_WIDTH_PIXELS_MIN = 30
const dayWidthPixels = ref(0)
let resizeObserver: ResizeObserver
const {tasks, filters} = toRefs(props)
const projectStore = useProjectStore()
@ -161,7 +163,7 @@ const dateToDate = computed(() => dayjs(filters.value.dateTo).endOf('day').toDat
const totalWidth = computed(() => {
const dateDiff = Math.ceil((dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY)
return dateDiff * DAY_WIDTH_PIXELS
return dateDiff * dayWidthPixels.value
})
const timelineData = computed(() => {
@ -318,6 +320,55 @@ function transformTaskToGanttBar(node: GanttTaskTreeNode): GanttBarModel {
}
}
function updateDayWidthPixels() {
const node = ganttContainer.value
if (!node) return
const rect = node.getBoundingClientRect()
const styles = window.getComputedStyle(node)
const marginLeft = parseFloat(styles.marginLeft) || 0
const marginRight = parseFloat(styles.marginRight) || 0
// max width without overflow
const maxWidth = rect.width - marginLeft - marginRight
const dayCount = Math.ceil(
(dateToDate.value.valueOf() - dateFromDate.value.valueOf()) / MILLISECONDS_A_DAY,
)
dayWidthPixels.value = Math.max(
maxWidth / dayCount,
DAY_WIDTH_PIXELS_MIN,
)
}
onMounted(async () => {
await nextTick()
updateDayWidthPixels()
if (ganttContainer.value) {
resizeObserver = new ResizeObserver(updateDayWidthPixels)
resizeObserver.observe(ganttContainer.value)
}
window.addEventListener('resize', updateDayWidthPixels)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updateDayWidthPixels)
})
watch(
[dateFromDate, dateToDate],
async () => {
await nextTick()
updateDayWidthPixels()
},
{flush: 'post'},
)
// Build the task tree when tasks change
watch(
[tasks, filters],
@ -372,7 +423,7 @@ const ROW_HEIGHT = 40
const barPositions = computed(() => {
const positions = new Map<number, GanttBarPosition>()
const ds = dragState.value
const dragPixelOffset = ds ? ds.currentDays * DAY_WIDTH_PIXELS : 0
const dragPixelOffset = ds ? ds.currentDays * dayWidthPixels.value : 0
ganttBars.value.forEach((rowBars, rowIndex) => {
for (const bar of rowBars) {
@ -407,7 +458,7 @@ function computeBarX(date: Date): number {
(roundToNaturalDayBoundary(date, true).getTime() - dateFromDate.value.getTime()) /
MILLISECONDS_A_DAY,
)
return diff * DAY_WIDTH_PIXELS
return diff * dayWidthPixels.value
}
function computeBarWidth(bar: GanttBarModel): number {
@ -415,7 +466,7 @@ function computeBarWidth(bar: GanttBarModel): number {
(roundToNaturalDayBoundary(bar.end).getTime() - roundToNaturalDayBoundary(bar.start, true).getTime()) /
MILLISECONDS_A_DAY,
)
return diff * DAY_WIDTH_PIXELS
return diff * dayWidthPixels.value
}
// Compute relation arrows
@ -611,7 +662,7 @@ function startDrag(bar: GanttBarModel, event: PointerEvent) {
if (!dragState.value || !isDragging.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / DAY_WIDTH_PIXELS)
const days = Math.round(diff / dayWidthPixels.value)
if (days !== dragState.value.currentDays) {
dragState.value.currentDays = days
@ -673,7 +724,7 @@ function startResize(bar: GanttBarModel, edge: 'start' | 'end', event: PointerEv
if (!dragState.value || !isResizing.value) return
const diff = e.clientX - dragState.value.startX
const days = Math.round(diff / DAY_WIDTH_PIXELS)
const days = Math.round(diff / dayWidthPixels.value)
if (edge === 'start') {
const newStart = new Date(dragState.value.originalStart)

View File

@ -722,7 +722,7 @@ async function addImage(event: Event) {
return
}
const url = await inputPrompt(event.target.getBoundingClientRect())
const url = await inputPrompt(event.target.getBoundingClientRect(), '', editor.value)
if (url) {
editor.value?.chain().focus().setImage({src: url}).run()

View File

@ -5,6 +5,7 @@ import {PluginKey, type EditorState} from '@tiptap/pm/state'
import EmojiList from './EmojiList.vue'
import {loadEmojis, filterEmojis, type EmojiEntry} from './emojiData'
import {getPopupContainer} from '../popupContainer'
export const EmojiSuggestionPluginKey = new PluginKey('emojiSuggestion')
@ -78,7 +79,7 @@ export default function emojiSuggestionSetup() {
popupElement.style.left = '0'
popupElement.style.zIndex = '4700'
popupElement.appendChild(component.element!)
document.body.appendChild(popupElement)
getPopupContainer(props.editor).appendChild(popupElement)
const rect = props.clientRect()
if (!rect) {
@ -108,7 +109,7 @@ export default function emojiSuggestionSetup() {
cleanupFloating = null
}
if (popupElement) {
document.body.removeChild(popupElement)
popupElement.remove()
popupElement = null
}
component?.destroy()

View File

@ -3,7 +3,7 @@ import inputPrompt from '@/helpers/inputPrompt'
export async function setLinkInEditor(pos: DOMRect, editor: Editor | null | undefined) {
const previousUrl = editor?.getAttributes('link').href || ''
const url = await inputPrompt(pos, previousUrl)
const url = await inputPrompt(pos, previousUrl, editor ?? undefined)
// empty
if (url === '') {

View File

@ -123,7 +123,7 @@
</XButton>
<!-- Dropzone -->
<Teleport to="body">
<Teleport :to="dropzoneTeleportTarget">
<div
v-if="editEnabled"
:class="{hidden: !showDropzone}"
@ -185,7 +185,7 @@
</template>
<script setup lang="ts">
import {ref, shallowReactive, computed, watch} from 'vue'
import {ref, shallowReactive, computed, watch, onMounted, onBeforeUnmount} from 'vue'
import {useDropZone} from '@vueuse/core'
import User from '@/components/misc/User.vue'
@ -322,6 +322,34 @@ const showDropzone = computed(() =>
props.editEnabled && isDraggingFiles.value && !isDragOverEditor.value,
)
// A <dialog> opened with showModal() (e.g. the Kanban task detail) renders in
// the browser's top layer, so the full-screen dropzone overlay teleported to
// <body> would paint behind it regardless of z-index. Teleport it into the
// topmost open dialog instead, mirroring Notification.vue.
const dropzoneTeleportTarget = ref<string | HTMLElement>('body')
let dialogObserver: MutationObserver | null = null
function syncDropzoneTeleportTarget() {
const dialogs = document.querySelectorAll<HTMLDialogElement>('dialog.modal-dialog[open]')
dropzoneTeleportTarget.value = dialogs.item(dialogs.length - 1) ?? 'body'
}
onMounted(() => {
syncDropzoneTeleportTarget()
dialogObserver = new MutationObserver(syncDropzoneTeleportTarget)
dialogObserver.observe(document.body, {
attributes: true,
attributeFilter: ['open'],
childList: true,
subtree: true,
})
})
onBeforeUnmount(() => {
dialogObserver?.disconnect()
dialogObserver = null
})
watch(() => props.editEnabled, enabled => {
if (!enabled) {
resetDragState()
@ -478,7 +506,7 @@ defineExpose({
inset-inline-start: 0;
inset-block-end: 0;
inset-inline-end: 0;
z-index: 4001; // modal z-index is 4000
z-index: 4001; // above app chrome when teleported to body (no modal open)
text-align: center;
&.hidden {

View File

@ -327,9 +327,17 @@ const isOverdue = computed(() => (
let oldTask
async function markAsDone(checked: boolean, wasReverted: boolean = false) {
const updateFunc = async () => {
oldTask = {...task.value}
const newTask = await taskStore.update(task.value)
oldTask = {...task.value}
// Fire the request immediately and with the intended done value snapshotted, so a re-render or
// teardown during the animation delay can neither drop the save nor make it send a stale state.
const updatePromise = taskStore.update({
...task.value,
done: checked,
})
const finish = async () => {
const newTask = await updatePromise
task.value = newTask
updateDueDate()
@ -355,9 +363,9 @@ async function markAsDone(checked: boolean, wasReverted: boolean = false) {
}
if (checked) {
setTimeout(updateFunc, 300) // Delay it to show the animation when marking a task as done
setTimeout(finish, 300) // Delay only the follow-up to show the animation when marking a task as done
} else {
await updateFunc() // Don't delay it when un-marking it as it doesn't have an animation the other way around
await finish() // Don't delay it when un-marking it as it doesn't have an animation the other way around
}
}

View File

@ -0,0 +1,34 @@
import {describe, it, expect} from 'vitest'
import {buildStoredQuery} from './useTaskList'
describe('buildStoredQuery', () => {
it('includes sort when set', () => {
expect(buildStoredQuery({sort: 'due_date:asc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'due_date:asc'})
})
it('includes filter and search when set', () => {
expect(buildStoredQuery({sort: undefined, filter: 'done = false', s: 'foo', page: 1}))
.toEqual({filter: 'done = false', s: 'foo'})
})
it('omits page when it equals the default of 1', () => {
expect(buildStoredQuery({sort: 'id:desc', filter: undefined, s: undefined, page: 1}))
.toEqual({sort: 'id:desc'})
})
it('includes page when greater than 1', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 3}))
.toEqual({page: '3'})
})
it('returns an empty object when nothing is set', () => {
expect(buildStoredQuery({sort: undefined, filter: undefined, s: undefined, page: 1}))
.toEqual({})
})
it('skips empty strings', () => {
expect(buildStoredQuery({sort: '', filter: '', s: '', page: 1}))
.toEqual({})
})
})

View File

@ -1,4 +1,6 @@
import {ref, shallowReactive, watch, computed, type ComputedGetter} from 'vue'
import {useRouter, isNavigationFailure} from 'vue-router'
import type {LocationQueryRaw} from 'vue-router'
import {useRouteQuery} from '@vueuse/router'
import TaskCollectionService, {
@ -10,6 +12,7 @@ import type {ITask} from '@/modelTypes/ITask'
import {error} from '@/message'
import type {IProject} from '@/modelTypes/IProject'
import {useAuthStore} from '@/stores/auth'
import {useViewFiltersStore} from '@/stores/viewFilters'
import type {IProjectView} from '@/modelTypes/IProjectView'
export type Order = 'asc' | 'desc' | 'none'
@ -58,6 +61,23 @@ function serializeSortBy(sortBy: SortBy, defaultSort: SortBy): string | undefine
const SORT_BY_DEFAULT: SortBy = {
id: 'desc',
}
interface TaskListQueryState {
sort: string | undefined
filter: string | undefined
s: string | undefined
page: number
}
export function buildStoredQuery(state: TaskListQueryState): LocationQueryRaw {
const query: LocationQueryRaw = {}
if (state.sort) query.sort = state.sort
if (state.filter) query.filter = state.filter
if (state.s) query.s = state.s
if (state.page > 1) query.page = String(state.page)
return query
}
// This makes sure an id sort order is always sorted last.
// When tasks would be sorted first by id and then by whatever else was specified, the id sort takes
// precedence over everything else, making any other sort columns pretty useless.
@ -94,6 +114,9 @@ export function useTaskList(
const projectId = computed(() => projectIdGetter())
const projectViewId = computed(() => projectViewIdGetter())
const router = useRouter()
const viewFiltersStore = useViewFiltersStore()
const params = ref<TaskFilterParams>({...getDefaultTaskFilterParams()})
const page = useRouteQuery('page', '1', { transform: Number })
@ -120,6 +143,55 @@ export function useTaskList(
},
})
// Mirror the URL query bits this composable owns into the store so
// in-project tab switches and sidebar re-visits can restore them.
//
// `ProjectList`/`ProjectTable` are reused across project switches (no
// `:key` on them in ProjectView.vue), so setup runs only once. We track
// the last viewId we synced — on every viewId transition, if the URL has
// none of our params and the store has an entry, restore it via
// `router.replace` and skip writing back the empty state we'd otherwise
// clobber the saved entry with.
let lastSyncedViewId: number | undefined
watch(
[projectViewId, sortQuery, filter, s, page],
([viewId, sortValue, filterValue, sValue, pageValue]) => {
const viewIdChanged = viewId !== lastSyncedViewId
lastSyncedViewId = viewId
// An invalid `?page=` becomes NaN via `transform: Number`; treat it as
// the default so it neither blocks restoration nor wipes stored state.
const currentPage = Number.isInteger(pageValue) ? pageValue : 1
const urlIsEmpty = !sortValue && !filterValue && !sValue && currentPage === 1
if (viewIdChanged && urlIsEmpty) {
const storedQuery = viewFiltersStore.getViewQuery(viewId)
if (Object.keys(storedQuery).length > 0) {
// Merge so unrelated query params on the route survive the restore.
// Swallow navigation failures (e.g. aborted/duplicated) so the
// ignored promise can't surface as an unhandled rejection.
router.replace({query: {...router.currentRoute.value.query, ...storedQuery}})
.catch(failure => {
if (!isNavigationFailure(failure)) throw failure
})
return
}
}
const query = buildStoredQuery({
sort: sortValue as string | undefined,
filter: filterValue as string | undefined,
s: sValue as string | undefined,
page: currentPage,
})
if (Object.keys(query).length > 0) {
viewFiltersStore.setViewQuery(viewId, query)
} else {
viewFiltersStore.clearViewQuery(viewId)
}
},
{immediate: true},
)
const allParams = computed(() => {
const loadParams = {...params.value}

View File

@ -0,0 +1,12 @@
/**
* Hash-fragment prefix used to carry a post-login destination in the URL.
*
* Unlike the localStorage redirect, this lives in the address bar so the URL
* stays copyable between browsers (needed for native OAuth clients that open
* /oauth/authorize, see #2654). It uses the hash not a query param so the
* embedded OAuth parameters never reach server or proxy access logs.
*
* Must stay distinct from LINK_SHARE_HASH_PREFIX, which router.beforeEach
* special-cases.
*/
export const REDIRECT_HASH_PREFIX = '#redirect='

View File

@ -0,0 +1,153 @@
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'
import {refreshToken, removeToken} from './auth'
// Count how many times the refresh endpoint is actually POSTed. The whole point
// of the in-flight dedup is that concurrent refreshToken() calls share a single
// underlying POST, independent of the Web Locks API.
let postCallCount = 0
let resolvePost: ((value: unknown) => void) | null = null
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => ({
post: vi.fn(() => {
postCallCount++
return new Promise((resolve) => {
resolvePost = resolve
})
}),
}),
}))
vi.mock('@/helpers/desktopAuth', () => ({
isDesktopApp: () => false,
refreshDesktopToken: vi.fn(),
}))
const FAKE_TOKEN = 'header.payload.signature'
function settlePost() {
resolvePost?.({data: {token: FAKE_TOKEN}})
}
describe('refreshToken in-flight dedup', () => {
const originalLocks = navigator.locks
beforeEach(() => {
postCallCount = 0
resolvePost = null
removeToken()
localStorage.clear()
})
afterEach(() => {
Object.defineProperty(navigator, 'locks', {
value: originalLocks,
configurable: true,
writable: true,
})
})
it('coalesces concurrent calls into a single POST when Web Locks is available', async () => {
// Stub a minimal Web Locks API: happy-dom leaves navigator.locks
// undefined, so without this the test would silently fall through to
// the insecure-HTTP branch and never exercise navigator.locks.request.
const requestSpy = vi.fn((_name: string, cb: () => unknown) => cb())
Object.defineProperty(navigator, 'locks', {
value: {request: requestSpy},
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
// Both calls share one underlying request.
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2])
// The Web Locks branch actually ran...
expect(requestSpy).toHaveBeenCalledWith('vikunja-token-refresh', expect.any(Function))
// ...and the in-flight dedup still collapsed both calls into one POST.
expect(postCallCount).toBe(1)
})
it('coalesces concurrent calls into a single POST on insecure HTTP (no Web Locks)', async () => {
// Simulate an insecure HTTP context where navigator.locks is undefined.
Object.defineProperty(navigator, 'locks', {
value: undefined,
configurable: true,
writable: true,
})
const p1 = refreshToken(true)
const p2 = refreshToken(true)
const p3 = refreshToken(true)
expect(postCallCount).toBe(1)
settlePost()
await Promise.all([p1, p2, p3])
expect(postCallCount).toBe(1)
})
it('allows a fresh refresh after the previous one settled', async () => {
const p1 = refreshToken(true)
settlePost()
await p1
expect(postCallCount).toBe(1)
// The in-flight promise was reset, so a later refresh runs anew.
const p2 = refreshToken(true)
expect(postCallCount).toBe(2)
settlePost()
await p2
})
it('does not re-persist the token when logout happens during an in-flight refresh', async () => {
const p1 = refreshToken(true)
expect(postCallCount).toBe(1)
// User logs out while the refresh POST is still in flight.
removeToken()
// The in-flight POST resolves afterwards — it must not undo the logout.
settlePost()
await p1
expect(localStorage.getItem('token')).toBeNull()
})
it('an older refresh settling does not clobber a newer in-flight one', async () => {
// Refresh A starts and stays in flight.
const pA = refreshToken(true)
expect(postCallCount).toBe(1)
const resolveA = resolvePost
// User logs out, which drops the in-flight reference to A.
removeToken()
// Refresh B starts; it must claim the in-flight slot.
const pB = refreshToken(true)
expect(postCallCount).toBe(2)
const resolveB = resolvePost
// A settles after B started. Its cleanup must NOT null the in-flight
// slot, since that slot now belongs to B. Without the `=== p` guard,
// A's .finally would clobber B and let a concurrent caller fire a
// second parallel POST.
resolveA?.({data: {token: FAKE_TOKEN}})
await pA
// A concurrent caller while B is still in flight must dedup to B —
// no third POST.
const pB2 = refreshToken(true)
expect(postCallCount).toBe(2)
resolveB?.({data: {token: FAKE_TOKEN}})
await Promise.all([pB, pB2])
})
})

View File

@ -33,18 +33,53 @@ export const removeToken = () => {
savedToken = null
localStorage.removeItem('token')
localStorage.removeItem('desktopOAuthRefreshToken')
// Bump the epoch and drop the in-flight refresh so a refresh that started
// before this logout can't re-persist a token after we cleared it.
authEpoch++
inFlightRefresh = null
}
// Coalesces concurrent same-tab refreshes into one POST. Web Locks (below) is
// secure-context-only, so on insecure HTTP there's no cross-tab coordination —
// without this guard, refreshes firing close together each spend the single-use
// cookie and all but one get a 401.
let inFlightRefresh: Promise<void> | null = null
// Incremented on every removeToken()/logout. A refresh captures the epoch when
// it starts and only persists its result if the epoch is unchanged, so a
// refresh that resolves after a logout can't undo it.
let authEpoch = 0
/**
* Refreshes an auth token while ensuring it is updated everywhere.
* The refresh token is sent automatically as an HttpOnly cookie.
* The server rotates the cookie on every call.
*
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
* performs the actual refresh; other tabs waiting for the lock detect that
* the token in localStorage was already updated and adopt it directly.
* Same-tab concurrent calls share one in-flight refresh (always-on dedup); the
* Web Locks API inside adds cross-tab coordination only in secure contexts.
*/
export async function refreshToken(persist: boolean): Promise<void> {
if (inFlightRefresh) {
return inFlightRefresh
}
const p = doRefresh(persist)
inFlightRefresh = p
// Only clear if it still points to this promise — a logout (or a newer
// refresh started after it) may have replaced inFlightRefresh meanwhile.
p.finally(() => {
if (inFlightRefresh === p) {
inFlightRefresh = null
}
})
return p
}
async function doRefresh(persist: boolean): Promise<void> {
// Snapshot the epoch so we can tell if a logout happened while we awaited.
const epochAtStart = authEpoch
const loggedOutSinceStart = () => authEpoch !== epochAtStart
// In desktop mode, refresh via IPC to the Electron main process
if (isDesktopApp()) {
const storedRefreshToken = localStorage.getItem('desktopOAuthRefreshToken')
@ -53,6 +88,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
}
try {
const tokens = await refreshDesktopToken(window.API_URL, storedRefreshToken)
if (loggedOutSinceStart()) {
return
}
saveToken(tokens.access_token, persist)
localStorage.setItem('desktopOAuthRefreshToken', tokens.refresh_token)
} catch (e) {
@ -65,7 +103,13 @@ export async function refreshToken(persist: boolean): Promise<void> {
// if another tab refreshed while we were queued.
const tokenBeforeLock = localStorage.getItem('token')
const doRefresh = async () => {
const refreshUnderLock = async () => {
// A logout may have happened while we waited for the lock — don't
// re-adopt or re-fetch a token after the user signed out.
if (loggedOutSinceStart()) {
return
}
// If the token in localStorage changed while waiting for the lock,
// another tab already refreshed. Just adopt the new token.
const currentToken = localStorage.getItem('token')
@ -78,6 +122,9 @@ export async function refreshToken(persist: boolean): Promise<void> {
const HTTP = HTTPFactory()
try {
const response = await HTTP.post('user/token/refresh')
if (loggedOutSinceStart()) {
return
}
saveToken(response.data.token, persist)
} catch (e) {
throw new Error('Error renewing token: ', {cause: e})
@ -85,10 +132,10 @@ export async function refreshToken(persist: boolean): Promise<void> {
}
if (navigator.locks) {
await navigator.locks.request('vikunja-token-refresh', doRefresh)
await navigator.locks.request('vikunja-token-refresh', refreshUnderLock)
} else {
// Fallback for environments without Web Locks (e.g. insecure HTTP)
await doRefresh()
await refreshUnderLock()
}
}

View File

@ -10,5 +10,9 @@ export function getProjectTitle(project: IProject) {
return i18n.global.t('project.inboxTitle')
}
if (project.title === 'My Open Tasks') {
return i18n.global.t('project.myOpenTasksFilterTitle')
}
return project.title
}

View File

@ -2,10 +2,17 @@ import {createRandomID} from '@/helpers/randomId'
import {computePosition, flip, shift, offset} from '@floating-ui/dom'
import {nextTick} from 'vue'
import {eventToShortcutString} from '@/helpers/shortcut'
import type {Editor} from '@tiptap/core'
import {getPopupContainer} from '@/components/input/editor/popupContainer'
export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Promise<string> {
export default function inputPrompt(pos: ClientRect, oldValue: string = '', editor?: Editor): Promise<string> {
return new Promise((resolve) => {
const id = 'link-input-' + createRandomID()
// Append inside the open task <dialog> (top-layer) when present, otherwise
// document.body. A body-level popup is painted behind a showModal() dialog
// and unfocusable through its focus trap, breaking the link prompt in the
// Kanban task popup (#2940).
const container = getPopupContainer(editor)
// Create popup element
const popupElement = document.createElement('div')
@ -26,7 +33,7 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
inputElement.value = oldValue
wrapperDiv.appendChild(inputElement)
popupElement.appendChild(wrapperDiv)
document.body.appendChild(popupElement)
container.appendChild(popupElement)
// Create a local mutable copy of the position for scroll tracking
let currentRect = new DOMRect(pos.left, pos.top, pos.width, pos.height)
@ -82,15 +89,41 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
nextTick(() => document.getElementById(id)?.focus())
// The prompt is a sub-modal of the enclosing task <dialog>. Native modal
// dialogs close themselves on Escape ("cancel"); swallow that while the
// prompt is open so Escape only dismisses the prompt, not the task dialog.
const dialog = container.closest('dialog') as HTMLDialogElement | null
const handleDialogCancel = (event: Event) => event.preventDefault()
dialog?.addEventListener('cancel', handleDialogCancel)
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
}
}
const cleanup = () => {
window.removeEventListener('scroll', handleScroll, true)
if (document.body.contains(popupElement)) {
document.body.removeChild(popupElement)
document.removeEventListener('click', handleClickOutside)
dialog?.removeEventListener('cancel', handleDialogCancel)
if (container.contains(popupElement)) {
container.removeChild(popupElement)
}
}
document.getElementById(id)?.addEventListener('keydown', event => {
const shortcutString = eventToShortcutString(event)
if (shortcutString === 'Escape') {
// Stop the native <dialog> from closing on Escape; cancel the prompt only.
event.preventDefault()
event.stopPropagation()
resolve('')
cleanup()
return
}
if (shortcutString !== 'Enter') {
return
}
@ -105,15 +138,6 @@ export default function inputPrompt(pos: ClientRect, oldValue: string = ''): Pro
cleanup()
})
// Close on click outside
const handleClickOutside = (event: MouseEvent) => {
if (!popupElement.contains(event.target as Node)) {
resolve('')
cleanup()
document.removeEventListener('click', handleClickOutside)
}
}
// Add slight delay to prevent immediate closing
setTimeout(() => {
document.addEventListener('click', handleClickOutside)

View File

@ -24,8 +24,10 @@ export const redirectToProvider = (provider: IProvider) => {
window.location.href = `${provider.authUrl}?client_id=${provider.clientId}&redirect_uri=${redirectUrl}&response_type=code&scope=${scope}&state=${state}`
}
export const redirectToProviderOnLogout = (provider: IProvider) => {
export const redirectToProviderOnLogout = (provider: IProvider): boolean => {
if (provider.logoutUrl.length > 0) {
window.location.href = `${provider.logoutUrl}`
return true
}
return false
}

View File

@ -393,6 +393,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -393,6 +393,7 @@
"title": "Dupliziere dieses Projekt",
"label": "Duplizieren",
"text": "Wähle ein übergeordnetes Projekt aus, welches das duplizierte Projekt enthalten soll:",
"shares": "Freigaben kopieren (Benutzer:innen, Teams und Linkfreigaben)",
"success": "Das Projekt wurde erfolgreich dupliziert."
},
"edit": {

View File

@ -393,6 +393,7 @@
"title": "Αντιγραφή του έργου",
"label": "Αντιγραφή",
"text": "Επιλέξτε ένα γονικό έργο που θα περιλαμβάνει το αντίγραφο του έργου:",
"shares": "Αντιγραφή διαμοιρασμών (χρήστες, ομάδες και σύνδεσμοι διαμοιρασμού) στο αντίγραφο",
"success": "Το έργο αντιγράφηκε με επιτυχία."
},
"edit": {

View File

@ -349,6 +349,7 @@
"shared": "Shared Projects",
"noDescriptionAvailable": "No project description is available.",
"inboxTitle": "Inbox",
"myOpenTasksFilterTitle": "My Open Tasks",
"favorite": "Mark this project as favorite",
"unfavorite": "Remove this project from favorites",
"openSettingsMenu": "Open project settings menu",
@ -393,6 +394,7 @@
"title": "Duplicate this project",
"label": "Duplicate",
"text": "Select a parent project which should hold the duplicated project:",
"shares": "Copy shares (users, teams and link shares) to the duplicate",
"success": "The project was successfully duplicated."
},
"edit": {

View File

@ -393,6 +393,7 @@
"title": "Дублювати цей проєкт",
"label": "Дублювати",
"text": "Оберіть батьківський проєкт, який повинен складатися з дубльованих проєктів:",
"shares": "Скопіювати налаштування спільного доступу (користувачів, команди та посилання) до копії проєкту",
"success": "Проєкт дубльовано."
},
"edit": {
@ -988,7 +989,7 @@
"assign": "Доручити",
"label": "Позначки",
"priority": "Встановити пріоритет",
"dueDate": "Встановити термін",
"dueDate": "Встановити термін виконання",
"startDate": "Почати",
"endDate": "Встановити дату завершення",
"reminders": "Нагадування",

View File

@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract {
projectId: number
duplicatedProject: IProject | null
parentProjectId: IProject['id']
duplicateShares: boolean
}

View File

@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel<IProjectDuplica
projectId = 0
duplicatedProject: IProject | null = null
parentProjectId = 0
duplicateShares = false
constructor(data : Partial<IProjectDuplicate>) {
super()

View File

@ -6,6 +6,7 @@ import {getProjectViewId} from '@/helpers/projectView'
import {parseDateOrString} from '@/helpers/time/parseDateOrString'
import {getNextWeekDate} from '@/helpers/time/getNextWeekDate'
import {LINK_SHARE_HASH_PREFIX} from '@/constants/linkShareHash'
import {REDIRECT_HASH_PREFIX} from '@/constants/redirectHash'
import {AUTH_ROUTE_NAMES} from '@/constants/authRouteNames'
import {PRO_FEATURE} from '@/constants/proFeatures'
@ -30,7 +31,7 @@ const router = createRouter({
}
// Scroll to anchor should still work
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX)) {
if (to.hash && !to.hash.startsWith(LINK_SHARE_HASH_PREFIX) && !to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
return {el: to.hash}
}
@ -472,10 +473,22 @@ const router = createRouter({
})
export async function getAuthForRoute(to: RouteLocation, authStore) {
// vue-router already decoded to.hash once, so slicing off the prefix yields the original
// fullPath (e.g. /oauth/authorize?...) losslessly — no extra decodeURIComponent needed.
const redirectDest = to.name === 'user.login' && to.hash.startsWith(REDIRECT_HASH_PREFIX)
? to.hash.slice(REDIRECT_HASH_PREFIX.length)
: ''
if (authStore.authUser || authStore.authLinkShare) {
// An already-signed-in browser that opens a copied /login#redirect=<oauth.authorize> URL
// must run the OAuth flow with its existing session instead of short-circuiting to home.
// The destination has no redirect hash, so the second guard pass just early-returns (#2654).
if (redirectDest) {
return redirectDest
}
return
}
// Check if password reset token is in query params
const resetToken = to.query.userPasswordReset as string | undefined
@ -499,15 +512,35 @@ export async function getAuthForRoute(to: RouteLocation, authStore) {
}
}
// Keep the destination in the address bar (not just per-browser localStorage) so a native
// client's /oauth/authorize URL stays copyable into another browser. Hash, not query, so the
// embedded OAuth params never reach access logs (#2654). Pass fullPath raw: vue-router encodes
// the hash itself, so an extra encodeURIComponent here would be double-encoded in the URL.
if (to.name === 'oauth.authorize') {
return {
name: 'user.login',
hash: REDIRECT_HASH_PREFIX + to.fullPath,
}
}
// Fold the hash destination into localStorage: it's the only bridge that survives the
// external OIDC round-trip out of the SPA, so redirectIfSaved() works after any auth method.
// vue-router already decoded to.hash once, so it equals the fullPath we wrote above as-is.
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
const destination = to.hash.slice(REDIRECT_HASH_PREFIX.length)
const resolved = router.resolve(destination)
saveLastVisited(resolved.name as string, resolved.params, resolved.query)
}
// Check if the route the user wants to go to is a route which needs authentication. We use this to
// redirect the user after successful login.
const isValidUserAppRoute = !AUTH_ROUTE_NAMES.has(to.name as string) &&
localStorage.getItem('emailConfirmToken') === null
if (isValidUserAppRoute) {
saveLastVisited(to.name as string, to.params, to.query)
}
if (isValidUserAppRoute) {
return {name: 'user.login'}
}
@ -565,12 +598,25 @@ router.beforeEach(async (to, from) => {
const newRoute = await getAuthForRoute(to, authStore)
if(newRoute) {
// A string target (the decoded redirect destination for an authed browser) already
// carries its own query/path and no redirect hash, so navigate to it verbatim — don't
// re-attach to.hash or it would re-enter the redirect loop.
if (typeof newRoute === 'string') {
return newRoute
}
return {
...newRoute,
hash: to.hash,
...newRoute,
}
}
// to.fullPath keeps the redirect hash url-encoded while to.hash is decoded, so the endsWith
// check below never matches and would re-append the hash forever. The hash is already on the
// URL here, so skip the re-attach (#2654).
if (to.hash.startsWith(REDIRECT_HASH_PREFIX)) {
return
}
if(!to.fullPath.endsWith(to.hash)) {
return to.fullPath + to.hash
}

View File

@ -0,0 +1,139 @@
import {describe, it, expect, beforeEach, vi} from 'vitest'
import {setActivePinia, createPinia} from 'pinia'
import {useAuthStore} from './auth'
import {AUTH_TYPES} from '@/modelTypes/IUser'
const {refreshTokenMock, routerPushMock, getTokenMock} = vi.hoisted(() => ({
refreshTokenMock: vi.fn(),
routerPushMock: vi.fn(),
getTokenMock: vi.fn(() => null as string | null),
}))
vi.mock('@/helpers/auth', () => ({
refreshToken: refreshTokenMock,
getToken: getTokenMock,
saveToken: vi.fn(),
removeToken: vi.fn(),
}))
vi.mock('@/router', () => ({
default: {push: routerPushMock},
}))
vi.mock('@/composables/useWebSocket', () => ({
useWebSocket: () => ({disconnect: vi.fn(), connect: vi.fn()}),
}))
function fakeHttp() {
return {
post: vi.fn().mockResolvedValue({data: {}}),
get: vi.fn().mockResolvedValue({data: {}}),
request: vi.fn().mockResolvedValue({data: {}}),
interceptors: {
request: {use: vi.fn()},
response: {use: vi.fn()},
},
}
}
vi.mock('@/helpers/fetcher', () => ({
HTTPFactory: () => fakeHttp(),
AuthenticatedHTTPFactory: () => fakeHttp(),
getApiBaseUrl: () => 'http://localhost/api/v1/',
}))
vi.mock('@/helpers/redirectToProvider', () => ({
getRedirectUrlFromCurrentFrontendPath: vi.fn(),
redirectToProvider: vi.fn(),
redirectToProviderOnLogout: vi.fn(),
}))
// A refresh failure that looks like a real network/HTTP error so renewToken's
// "is this a genuine logout?" check (it inspects the error cause's status) fires.
function refreshError() {
return new Error('Error renewing token: ', {
cause: {response: {status: 401}},
})
}
// A JWT carrying a not-yet-expired user session, so the checkAuth() call that
// renewToken() runs after a successful refresh treats the session as live.
function freshUserJwt() {
const payload = {
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) + 3600,
}
const encoded = btoa(JSON.stringify(payload))
return `header.${encoded}.signature`
}
describe('auth store renewToken retry (issue #2863)', () => {
beforeEach(() => {
setActivePinia(createPinia())
refreshTokenMock.mockReset()
routerPushMock.mockReset()
getTokenMock.mockReset().mockReturnValue(null)
})
function setupExpiredUserSession(store: ReturnType<typeof useAuthStore>) {
store.setAuthenticated(true)
// Expired exp so renewToken treats a refresh failure as a real logout.
store.setUser({
id: 1,
type: AUTH_TYPES.USER,
exp: Math.floor(Date.now() / 1000) - 60,
} as never, false)
}
it('does NOT log out when the first refresh fails but the retry succeeds', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
// The retry "succeeds" only if it actually leaves a usable token behind:
// renewToken() runs checkAuth() afterwards, which reads getToken(). Start
// with no token, then hand back a fresh JWT once the refresh resolves.
getTokenMock.mockReturnValue(null)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockImplementationOnce(async () => {
getTokenMock.mockReturnValue(freshUserJwt())
})
await store.renewToken()
// Two refresh attempts: the initial one and the single retry.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
// The retry recovered the session: the user is still authenticated...
expect(store.authenticated).toBe(true)
// ...and was not bounced to login.
expect(routerPushMock).not.toHaveBeenCalledWith({name: 'user.login'})
})
it('logs out when BOTH the refresh and its retry fail', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock
.mockRejectedValueOnce(refreshError())
.mockRejectedValueOnce(refreshError())
await store.renewToken()
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
expect(routerPushMock).toHaveBeenCalledWith({name: 'user.login'})
})
it('retries exactly once (no infinite loop) when the session is genuinely dead', async () => {
const store = useAuthStore()
setupExpiredUserSession(store)
refreshTokenMock.mockRejectedValue(refreshError())
await store.renewToken()
// Initial attempt + exactly one retry — never more.
expect(refreshTokenMock).toHaveBeenCalledTimes(2)
})
})

View File

@ -28,6 +28,11 @@ import {TIME_FORMAT} from '@/constants/timeFormat'
import {RELATION_KIND} from '@/types/IRelationKind'
import type {IProvider} from '@/types/IProvider'
// Set on explicit logout so the login page won't immediately bounce the user
// back to the OIDC provider. Lives in sessionStorage so it survives the
// round-trip to the IdP within the tab and isn't wiped by localStorage.clear().
export const JUST_LOGGED_OUT_KEY = 'justLoggedOut'
function redirectToSpecifiedProvider() {
const {auth} = useConfigStore()
@ -55,6 +60,17 @@ function redirectToSpecifiedProvider() {
}
}
// A race-loser's refresh fails but the rotated cookie is already valid, so a
// second attempt succeeds — recovering what would otherwise be a spurious
// logout. Exactly one retry: a genuinely dead session still logs out, no loop.
async function refreshTokenWithRetry(persist: boolean): Promise<void> {
try {
await refreshToken(persist)
} catch {
await refreshToken(persist)
}
}
function getLoggedInVia(): string | null {
return localStorage.getItem('loggedInViaProvider')
}
@ -352,7 +368,7 @@ export const useAuthStore = defineStore('auth', () => {
// refresh before giving up. This lets users who reopen the app
// after the short JWT TTL seamlessly resume their session.
try {
await refreshToken(true)
await refreshTokenWithRetry(true)
const freshJwt = getToken()
if (freshJwt) {
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@ -512,7 +528,7 @@ export const useAuthStore = defineStore('auth', () => {
saveToken(response.data.token, false)
} else {
// User sessions renew via the refresh-token cookie.
await refreshToken(true)
await refreshTokenWithRetry(true)
}
await checkAuth()
} catch (e) {
@ -533,9 +549,11 @@ export const useAuthStore = defineStore('auth', () => {
// Revoke the server session so the refresh token can't be reused.
// Best-effort: if the network call fails, still clean up locally.
let oidcLogoutUrl = ''
try {
const HTTP = AuthenticatedHTTPFactory()
await HTTP.post('user/logout')
const {data} = await HTTP.post('user/logout')
oidcLogoutUrl = data?.oidc_logout_url ?? ''
} catch (_e) {
// Ignore — session will expire naturally
}
@ -544,14 +562,25 @@ export const useAuthStore = defineStore('auth', () => {
const loggedInVia = getLoggedInVia()
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
lastUserInfoRefresh.value = null
sessionStorage.setItem(JUST_LOGGED_OUT_KEY, 'true')
// Redirect to the OIDC provider to end its session too. Prefer the
// server-built RP-Initiated Logout URL, falling back to the static one.
// These full-page redirects return the user to the login page, so we
// must not router.push there first — that would consume
// JUST_LOGGED_OUT_KEY before the round-trip lands.
if (oidcLogoutUrl) {
window.location.href = oidcLogoutUrl
return
}
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider && redirectToProviderOnLogout(fullProvider)) {
return
}
await router.push({name: 'user.login'})
await checkAuth()
// if configured, redirect to OIDC Provider on logout
const fullProvider: IProvider|undefined = configStore.auth.openidConnect.providers?.find((p: IProvider) => p.key === loggedInVia)
if (fullProvider) {
redirectToProviderOnLogout(fullProvider)
}
}
return {

View File

@ -47,6 +47,7 @@ export interface ConfigState {
publicTeamsEnabled: boolean,
allowIconChanges: boolean,
enabledProFeatures: string[],
concurrentWrites: boolean,
}
export const useConfigStore = defineStore('config', () => {
@ -88,6 +89,7 @@ export const useConfigStore = defineStore('config', () => {
publicTeamsEnabled: false,
allowIconChanges: true,
enabledProFeatures: [],
concurrentWrites: false,
})
const migratorsEnabled = computed(() => state.availableMigrators?.length > 0)

View File

@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter<IProject['id']>) {
success({message: t('project.edit.success')})
}
async function duplicateProject(parentProjectId: IProject['id']) {
async function duplicateProject(parentProjectId: IProject['id'], duplicateShares: boolean = false) {
const projectDuplicate = new ProjectDuplicateModel({
projectId: Number(toValue(projectId)),
parentProjectId,
duplicateShares,
})
const duplicate = await projectDuplicateService.create(projectDuplicate)

View File

@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest'
import {buildDefaultRemindersForQuickAdd} from './tasks'
import {buildDefaultRemindersForQuickAdd, runWrites} from './tasks'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import type {ITaskReminder} from '@/modelTypes/ITaskReminder'
@ -42,3 +42,39 @@ describe('buildDefaultRemindersForQuickAdd', () => {
expect(result[0].relativeTo).toBe(REMINDER_PERIOD_RELATIVE_TO_TYPES.DUEDATE)
})
})
describe('runWrites', () => {
function deferredWrite() {
const inFlight: string[] = []
let maxConcurrent = 0
const completed: string[] = []
const write = async (item: string) => {
inFlight.push(item)
maxConcurrent = Math.max(maxConcurrent, inFlight.length)
await Promise.resolve()
inFlight.splice(inFlight.indexOf(item), 1)
completed.push(item)
}
return {write, completed, getMaxConcurrent: () => maxConcurrent}
}
it('runs all writes in parallel when concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, true)
expect(completed).toHaveLength(3)
expect(getMaxConcurrent()).toBeGreaterThan(1)
})
it('runs writes one at a time when not concurrent', async () => {
const {write, completed, getMaxConcurrent} = deferredWrite()
await runWrites(['a', 'b', 'c'], write, false)
expect(completed).toEqual(['a', 'b', 'c'])
expect(getMaxConcurrent()).toBe(1)
})
it('does nothing for an empty list', async () => {
const {write, completed} = deferredWrite()
await runWrites([], write, false)
expect(completed).toHaveLength(0)
})
})

View File

@ -27,6 +27,7 @@ import type {IProject} from '@/modelTypes/IProject'
import {REMINDER_PERIOD_RELATIVE_TO_TYPES} from '@/types/IReminderPeriodRelativeTo'
import {setModuleLoading} from '@/stores/helper'
import {useConfigStore} from '@/stores/config'
import {useLabelStore} from '@/stores/labels'
import {useProjectStore} from '@/stores/projects'
import {useKanbanStore} from '@/stores/kanban'
@ -59,6 +60,22 @@ export function buildDefaultRemindersForQuickAdd(
}))
}
// runWrites applies a write to each item. SQLite deadlocks on concurrent writes
// (read-then-write upgrade conflict), so callers pass concurrent=false to serialize.
export async function runWrites<T>(
items: readonly T[],
write: (item: T) => Promise<unknown>,
concurrent: boolean,
): Promise<void> {
if (concurrent) {
await Promise.all(items.map(item => write(item)))
return
}
for (const item of items) {
await write(item)
}
}
// IDEA: maybe use a small fuzzy search here to prevent errors
function findPropertyByValue(object, key, value, fuzzy = false) {
return Object.values(object).find(l => {
@ -131,6 +148,7 @@ export const useTaskStore = defineStore('task', () => {
const labelStore = useLabelStore()
const projectStore = useProjectStore()
const authStore = useAuthStore()
const configStore = useConfigStore()
const tasks = ref<{ [id: ITask['id']]: ITask }>({}) // TODO: or is this ITask[]
const isLoading = ref(false)
@ -395,10 +413,7 @@ export const useTaskStore = defineStore('task', () => {
}
const labels = await ensureLabelsExist(parsedLabels)
const labelAddsToWaitFor = labels.map(async l => addLabelToTask(task, l))
// This waits until all labels are created and added to the task
await Promise.all(labelAddsToWaitFor)
await runWrites(labels, l => addLabelToTask(task, l), configStore.concurrentWrites)
return task
}

View File

@ -8,6 +8,12 @@
>
<p>{{ $t('project.duplicate.text') }}</p>
<ProjectSearch v-model="parentProject" />
<FancyCheckbox
v-model="duplicateShares"
class="mbs-2"
>
{{ $t('project.duplicate.shares') }}
</FancyCheckbox>
</CreateEdit>
</template>
@ -18,6 +24,7 @@ import {useI18n} from 'vue-i18n'
import CreateEdit from '@/components/misc/CreateEdit.vue'
import ProjectSearch from '@/components/tasks/partials/ProjectSearch.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import {success} from '@/message'
import {useTitle} from '@/composables/useTitle'
@ -33,6 +40,7 @@ const projectStore = useProjectStore()
const {project, isLoading, duplicateProject} = useProject(route.params.projectId)
const parentProject = ref<IProject | null>(null)
const duplicateShares = ref(true)
const isDuplicating = ref(false)
const loadingModel = computed({
@ -53,7 +61,7 @@ async function duplicate() {
isDuplicating.value = true
try {
await duplicateProject(parentProject.value?.id ?? 0)
await duplicateProject(parentProject.value?.id ?? 0, duplicateShares.value)
success({message: t('project.duplicate.success')})
} finally {
isDuplicating.value = false

View File

@ -136,7 +136,7 @@ import {redirectToProvider} from '@/helpers/redirectToProvider'
import {useRedirectToLastVisited} from '@/composables/useRedirectToLastVisited'
import {isDesktopApp} from '@/helpers/desktopAuth'
import {useAuthStore} from '@/stores/auth'
import {useAuthStore, JUST_LOGGED_OUT_KEY} from '@/stores/auth'
import {useConfigStore} from '@/stores/config'
import {useTitle} from '@/composables/useTitle'
@ -181,6 +181,25 @@ onBeforeMount(() => {
// route before the submit() handler gets a chance to use it.
if (authenticated.value) {
router.push({name: 'home'})
return
}
// Don't auto-redirect right after an explicit logout, otherwise we'd
// immediately re-authenticate the user we just logged out.
if (sessionStorage.getItem(JUST_LOGGED_OUT_KEY)) {
sessionStorage.removeItem(JUST_LOGGED_OUT_KEY)
return
}
// When the login page offers nothing but a single OIDC provider, skip it
// and send the user straight there.
if (
!localAuthEnabled.value &&
!ldapAuthEnabled.value &&
hasOpenIdProviders.value &&
openidConnect.value.providers.length === 1
) {
redirectToProvider(openidConnect.value.providers[0])
}
})

View File

@ -0,0 +1,124 @@
import {test, expect} from '../../support/fixtures'
import {ProjectFactory} from '../../factories/project'
import {ProjectViewFactory} from '../../factories/project_view'
import {BucketFactory} from '../../factories/bucket'
import {TaskFactory} from '../../factories/task'
import {TaskBucketFactory} from '../../factories/task_buckets'
// Regression test for #2940: in the Kanban task popup the description editor is
// rendered inside a native <dialog> opened via showModal() (browser top-layer).
// The link prompt used to be appended to document.body, so it was painted behind
// the dialog and unfocusable through its focus trap, making "set link" a no-op.
test.describe('Editor link prompt inside the Kanban task popup', () => {
test('creates a link in the description when opened as the Kanban popup', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
// The task popup must be a native <dialog> in the top layer.
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.fill('https://vikunja.io')
await urlInput.press('Enter')
const link = editor.locator('a[href="https://vikunja.io"]')
await expect(link).toBeVisible()
await expect(link).toHaveText('link me')
})
// The link prompt is a sub-modal of the task <dialog>: pressing Escape while
// it is open must cancel only the prompt and leave the task dialog open,
// instead of falling through to the native <dialog>'s Escape-to-close.
test('Escape cancels the link prompt without closing the task dialog', async ({authenticatedPage: page}) => {
const projects = await ProjectFactory.create(1)
const views = await ProjectViewFactory.create(1, {
id: 1,
project_id: projects[0].id,
view_kind: 3,
bucket_configuration_mode: 1,
})
const buckets = await BucketFactory.create(1, {
project_view_id: views[0].id,
})
const tasks = await TaskFactory.create(1, {
project_id: projects[0].id,
description: 'link me',
index: 1,
})
await TaskBucketFactory.create(1, {
task_id: tasks[0].id,
bucket_id: buckets[0].id,
project_view_id: views[0].id,
})
await page.goto(`/projects/${projects[0].id}/${views[0].id}`)
const card = page.locator('.kanban .bucket .tasks .task').filter({hasText: tasks[0].title})
await expect(card).toBeVisible()
await card.click()
const dialog = page.locator('dialog[open]')
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
const editButton = dialog.locator('.details.content.description .tiptap button.done-edit').filter({hasText: 'Edit'})
await expect(editButton).toBeVisible({timeout: 10000})
await editButton.click()
const description = dialog.locator('.details.content.description')
const editor = description.locator('[contenteditable="true"]').first()
await expect(editor).toBeVisible({timeout: 10000})
await editor.click()
await page.keyboard.press('ControlOrMeta+a')
await description.locator('.editor-toolbar__button').filter({hasText: 'Link'}).click()
const urlInput = dialog.locator('input.input[placeholder="URL"]')
await expect(urlInput).toBeVisible()
await urlInput.press('Escape')
// The prompt is gone, but the task dialog stays open.
await expect(urlInput).toBeHidden()
await expect(dialog).toBeVisible()
await expect(dialog.locator('.task-view')).toBeVisible()
})
})

View File

@ -0,0 +1,55 @@
import {type Page} from '@playwright/test'
import {test, expect} from '../../support/fixtures'
import {TaskFactory} from '../../factories/task'
import {createProjects} from './prepareProjects'
async function selectSortInList(page: Page, optionLabel: string) {
await page.locator('.filter-container').getByRole('button', {name: 'Sort', exact: true}).click()
await page.getByLabel('Sort by').selectOption({label: optionLabel})
await page.getByRole('button', {name: 'Apply sort'}).click()
}
async function navigateViaSidebar(page: Page, projectTitle: string) {
await page.locator('.menu-list .list-menu-link', {
has: page.locator('.project-menu-title', {hasText: new RegExp(`^${projectTitle}$`)}),
}).first().click()
}
test.describe('Sort persistence across sidebar navigation (#2753)', () => {
test('List view: sort persists after navigating to another project and back', async ({authenticatedPage: page}) => {
const projects = await createProjects(2)
const [projectA, projectB] = projects
await TaskFactory.create(3, {
id: '{increment}',
project_id: projectA.id,
title: 'Task {increment}',
})
const listViewA = projectA.views[0].id
await page.goto(`/projects/${projectA.id}/${listViewA}`)
await expect(page).not.toHaveURL(/sort=/)
await selectSortInList(page, 'Due date (Earliest first)')
await expect(page).toHaveURL(/sort=due_date:asc/)
await navigateViaSidebar(page, projectB.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectB.id}/`))
await navigateViaSidebar(page, projectA.title)
await expect(page).toHaveURL(new RegExp(`/projects/${projectA.id}/`))
await expect(page).toHaveURL(/sort=due_date:asc/)
})
test('List view: explicit URL sort wins over stored sort', async ({authenticatedPage: page}) => {
const projects = await createProjects(1)
const listView = projects[0].views[0].id
// Seed the store with one sort by visiting with it set.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=due_date:asc`)
await expect(page).toHaveURL(/sort=due_date:asc/)
// Visit a URL that explicitly sets a different sort — that should win.
await page.goto(`/projects/${projects[0].id}/${listView}?sort=priority:desc`)
await expect(page).toHaveURL(/sort=priority:desc/)
})
})

View File

@ -32,10 +32,20 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
})
// Navigate to the OAuth authorize frontend route.
// The user is not logged in, so the router guard saves the route
// and redirects to /login.
// The user is not logged in, so the router guard redirects to /login while
// carrying the authorize destination in a copyable #redirect= hash (not a
// query param, to keep the OAuth params out of access logs).
await page.goto(`/oauth/authorize?${authorizeParams}`)
await expect(page).toHaveURL(/\/login/)
await expect(page).toHaveURL(/\/login#redirect=/)
// The decoded #redirect= destination must carry the full authorize URL, including the
// OAuth params — checking only for the path would pass even if the query were dropped.
const redirectHash = decodeURIComponent(new URL(page.url()).hash)
expect(redirectHash).toContain('/oauth/authorize')
expect(redirectHash).toContain('response_type=code')
expect(redirectHash).toContain('client_id=vikunja')
expect(redirectHash).toContain(`code_challenge=${codeChallenge}`)
expect(redirectHash).toContain(`state=${state}`)
// Register the response listener BEFORE clicking Login, because after
// login redirectIfSaved() navigates back to /oauth/authorize and the
@ -77,4 +87,70 @@ test.describe('OAuth 2.0 Authorization Flow', () => {
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
// The primary #2654 scenario: the native client opened a different default browser that is
// already signed in to Vikunja. Opening the copied /login#redirect=<oauth.authorize> URL must
// run the OAuth flow with the existing session instead of short-circuiting to home.
test('Already-authenticated browser opening the copied login redirect runs the authorize flow', async ({authenticatedPage, apiContext, currentUser}) => {
const page = authenticatedPage
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
const state = randomBytes(16).toString('base64url')
const authorizeParams = new URLSearchParams({
response_type: 'code',
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state,
})
// The component POSTs as soon as it mounts with the existing session, so register the
// listener before navigating.
const authorizeResponsePromise = page.waitForResponse(
response => response.url().includes('/api/v1/oauth/authorize') && response.request().method() === 'POST',
{timeout: 15000},
)
// Open the copyable login URL exactly as it would be pasted from another browser
// (#redirect= is REDIRECT_HASH_PREFIX from @/constants/redirectHash, inlined here because
// the e2e runner has no @ alias).
const redirectDestination = `/oauth/authorize?${authorizeParams}`
await page.goto(`/login#redirect=${encodeURIComponent(redirectDestination)}`)
// The authed guard must send us straight to /oauth/authorize, not home.
await expect(page).toHaveURL(/\/oauth\/authorize/)
const landed = new URL(page.url())
expect(landed.pathname).toBe('/oauth/authorize')
expect(landed.searchParams.get('response_type')).toBe('code')
expect(landed.searchParams.get('client_id')).toBe('vikunja')
expect(landed.searchParams.get('code_challenge')).toBe(codeChallenge)
expect(landed.searchParams.get('state')).toBe(state)
// The PKCE flow completes with the existing session — no second login.
const authorizeResponse = await authorizeResponsePromise
const authorizeBody = await authorizeResponse.json()
expect(authorizeBody.code).toBeTruthy()
expect(authorizeBody.redirect_uri).toBe('vikunja-flutter://callback')
expect(authorizeBody.state).toBe(state)
const tokenResponse = await apiContext.post('oauth/token', {
data: {
grant_type: 'authorization_code',
code: authorizeBody.code,
client_id: 'vikunja',
redirect_uri: 'vikunja-flutter://callback',
code_verifier: codeVerifier,
},
})
expect(tokenResponse.ok()).toBe(true)
const tokenBody = await tokenResponse.json()
expect(tokenBody.access_token).toBeTruthy()
expect(tokenBody.refresh_token).toBeTruthy()
expect(tokenBody.token_type).toBe('bearer')
expect(tokenBody.expires_in).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,55 @@
// 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 <https://www.gnu.org/licenses/>.
package migration
import (
"time"
"src.techknowlogick.com/xormigrate"
"xorm.io/xorm"
)
// Mirrors models.Session; adds the two columns RP-Initiated Logout needs.
type sessionOIDCLogout20260619155410 struct {
ID string `xorm:"varchar(36) not null unique pk"`
UserID int64 `xorm:"bigint not null index"`
TokenHash string `xorm:"varchar(64) not null unique index"`
DeviceInfo string `xorm:"text"`
IPAddress string `xorm:"varchar(100)"`
IsLongSession bool `xorm:"not null default false"`
OIDCIDToken string `xorm:"text"`
OIDCProviderKey string `xorm:"varchar(250)"`
LastActive time.Time `xorm:"not null"`
Created time.Time `xorm:"created not null"`
}
func (sessionOIDCLogout20260619155410) TableName() string {
return "sessions"
}
func init() {
migrations = append(migrations, &xormigrate.Migration{
ID: "20260619155410",
Description: "Add oidc_id_token and oidc_provider_key columns to sessions for RP-Initiated Logout",
Migrate: func(tx *xorm.Engine) error {
return tx.Sync(sessionOIDCLogout20260619155410{})
},
Rollback: func(tx *xorm.Engine) error {
return nil
},
})
}

View File

@ -427,7 +427,8 @@ func CanDoAPIRoute(c *echo.Context, token *APIToken) (can bool) {
// Two list endpoints share tasks.read_all but only one
// survives collection, so allow either explicitly.
if group == "tasks" && p == "read_all" && method == http.MethodGet &&
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks") {
(path == "/api/v1/tasks" || path == "/api/v1/projects/:project/tasks" ||
path == "/api/v2/tasks" || path == "/api/v2/projects/:project/tasks") {
return true
}
}

View File

@ -246,6 +246,40 @@ func TestCanDoAPIRoute_V2PatchAliasesPut(t *testing.T) {
})
}
// TestCanDoAPIRoute_V2TasksReadAll verifies that tasks.read_all authorises
// both the global /api/v2/tasks and project-scoped /api/v2/projects/:project/tasks
// endpoints. Both normalise to tasks.read_all via getRouteGroupName, but only
// one RouteDetail survives in the map — the special case in CanDoAPIRoute must
// accept either path.
func TestCanDoAPIRoute_V2TasksReadAll(t *testing.T) {
apiTokenRoutes = make(map[string]APITokenRoute)
apiTokenRoutesV2 = make(map[string]APITokenRoute)
apiTokenRoutes["caldav"] = APITokenRoute{
"access": &RouteDetail{Path: "/dav/*", Method: "ANY"},
}
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/tasks"}, true)
CollectRoutesForAPITokenUsage(echo.RouteInfo{Method: "GET", Path: "/api/v2/projects/:project/tasks"}, true)
token := &APIToken{
APIPermissions: APIPermissions{"tasks": []string{"read_all"}},
}
e := echo.New()
t.Run("global /api/v2/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
t.Run("project-scoped /api/v2/projects/:project/tasks", func(t *testing.T) {
req := httptest.NewRequest("GET", "/api/v2/projects/:project/tasks", nil)
c := e.NewContext(req, httptest.NewRecorder())
assert.True(t, CanDoAPIRoute(c, token))
})
}
// End-to-end CanDoAPIRoute coverage for /api/v2 is provided by the Label
// integration test in pkg/webtests/huma_label_test.go (see the token-auth
// scenarios in that file) which exercises the full auth pipeline.

View File

@ -22,7 +22,9 @@ import (
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
"code.vikunja.io/api/pkg/web"
"xorm.io/xorm"
"xorm.io/xorm/schemas"
)
// TaskBucket represents the relation between a task and a kanban bucket.
@ -58,27 +60,19 @@ func (b *TaskBucket) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) {
}
func (b *TaskBucket) upsert(s *xorm.Session) (err error) {
count, err := s.Where("task_id = ? AND project_view_id = ?", b.TaskID, b.ProjectViewID).
Cols("bucket_id").
Update(b)
if err != nil {
return
}
if count == 0 {
_, err = s.Insert(b)
if err != nil {
// Check if this is a unique constraint violation for the task_buckets table
if db.IsUniqueConstraintError(err, "UQE_task_buckets_task_project_view") {
return ErrTaskAlreadyExistsInBucket{
TaskID: b.TaskID,
ProjectViewID: b.ProjectViewID,
}
}
return
}
// A native upsert moves the task in one atomic statement, without
// depending on the affected-row count (MySQL/MariaDB report 0 affected
// rows for an unchanged value).
onConflict := "ON CONFLICT (task_id, project_view_id) DO UPDATE SET bucket_id = excluded.bucket_id"
if db.Type() == schemas.MYSQL {
onConflict = "ON DUPLICATE KEY UPDATE bucket_id = VALUES(bucket_id)"
}
// Raw SQL bypasses xorm's bean-based table-name handling, so qualify the
// table ourselves to honor a configured postgres schema (database.schema).
table := s.Engine().TableName(b, true)
query := "INSERT INTO " + table + " (task_id, project_view_id, bucket_id) VALUES (?, ?, ?) " + onConflict
_, err = s.Exec(query, b.TaskID, b.ProjectViewID, b.BucketID)
return
}
@ -151,10 +145,8 @@ func updateTaskBucket(s *xorm.Session, a web.Auth, b *TaskBucket) (err error) {
if err != nil {
return err
}
// If the task is already in the default bucket, skip the
// upsert — MySQL's UPDATE returns 0 affected rows when
// the value is unchanged, which would make upsert fall
// through to INSERT and hit the unique constraint.
// The task is already in the default bucket, so there is
// nothing to move and no count to bump.
if b.BucketID == oldTaskBucket.BucketID {
updateBucket = false
}

View File

@ -226,6 +226,63 @@ func TestTaskBucket_Update(t *testing.T) {
})
})
t.Run("done task already in another view's done bucket", func(t *testing.T) {
// Regression test: marking a task done syncs it into the done bucket
// of every kanban view in the project. When the task already sits in
// such a view's done bucket the sync is a no-op update, but on
// MySQL/MariaDB an UPDATE that doesn't change the value reports 0
// affected rows. The upsert then mistook that for "row missing" and
// inserted, hitting the unique index with ErrTaskAlreadyExistsInBucket.
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// A second manual kanban view on project 1. Creating it auto-generates
// the To-Do/Doing/Done buckets and sets its done bucket.
secondView := &ProjectView{
Title: "Second Kanban",
ProjectID: 1,
ViewKind: ProjectViewKindKanban,
BucketConfigurationMode: BucketConfigurationModeManual,
}
err := secondView.Create(s, u)
require.NoError(t, err)
require.NotZero(t, secondView.DoneBucketID)
// Pre-place task 1 in the second view's done bucket without going
// through the done-sync, so the task itself is still open and view 4
// still has it in its default bucket.
_, err = s.Where("task_id = ? AND project_view_id = ?", 1, secondView.ID).
Cols("bucket_id").
Update(&TaskBucket{BucketID: secondView.DoneBucketID})
require.NoError(t, err)
// Moving task 1 into view 4's done bucket marks it done and triggers
// the cross-view sync into the second view's done bucket, where it
// already lives. This must succeed rather than error.
tb := &TaskBucket{
TaskID: 1,
BucketID: 3, // done bucket on view 4
ProjectViewID: 4,
ProjectID: 1,
}
err = tb.Update(s, u)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.True(t, tb.Task.Done)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"bucket_id": 3,
}, false)
db.AssertExists(t, "task_buckets", map[string]interface{}{
"task_id": 1,
"project_view_id": secondView.ID,
"bucket_id": secondView.DoneBucketID,
}, false)
})
t.Run("saved filter: first task into empty limited bucket is allowed", func(t *testing.T) {
// Regression test for #2672: on a saved-filter kanban view the bucket
// limit was checked against the total number of tasks matching the

View File

@ -760,6 +760,15 @@ func getRawProjectsForUser(s *xorm.Session, opts *projectOptions) (projects []*P
return allProjects, len(allProjects), totalItems, err
}
func CreateDefaultSavedFiltersForUser(s *xorm.Session, u *user.User) error {
sf := &SavedFilter{
Title: "My Open Tasks",
Filters: &TaskCollection{Filter: fmt.Sprintf("done = false && assignees = %s", u.Username)},
}
return sf.Create(s, u)
}
func getSavedFilterProjects(s *xorm.Session, doer *user.User, search string) (savedFiltersProjects []*Project, err error) {
savedFilters, err := getSavedFiltersForUser(s, doer, search)
if err != nil {
@ -1108,6 +1117,10 @@ func RegisterUser(s *xorm.Session, u *user.User) (*user.User, error) {
return nil, err
}
if err := CreateDefaultSavedFiltersForUser(s, newUser); err != nil {
return nil, err
}
return newUser, nil
}

View File

@ -34,6 +34,8 @@ type ProjectDuplicate struct {
ProjectID int64 `json:"-" param:"projectid"`
// The target parent project
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."`
// Whether to copy the project's shares to the duplicate
DuplicateShares bool `json:"duplicate_shares,omitempty" doc:"Whether to copy the project's user, team and link shares to the duplicate. Defaults to false."`
// The copied project
Project *Project `json:"duplicated_project,omitempty" readOnly:"true" doc:"The newly created duplicate project, populated by the server in the response."`
@ -62,7 +64,7 @@ func (pd *ProjectDuplicate) CanCreate(s *xorm.Session, a web.Auth) (canCreate bo
// Create duplicates a project
// @Summary Duplicate an existing project
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.
// @Description Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.
// @tags project
// @Accept json
// @Produce json
@ -117,56 +119,58 @@ func (pd *ProjectDuplicate) Create(s *xorm.Session, doer web.Auth) (err error) {
return
}
// Permissions / Shares
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
users := []*ProjectUser{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
if err != nil {
return
}
for _, u := range users {
u.ID = 0
u.ProjectID = pd.Project.ID
if _, err := s.Insert(u); err != nil {
return err
}
}
log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID)
teams := []*TeamProject{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&teams)
if err != nil {
return
}
for _, t := range teams {
t.ID = 0
t.ProjectID = pd.Project.ID
if _, err := s.Insert(t); err != nil {
return err
}
}
// Generate new link shares if any are available
linkShares := []*LinkSharing{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares)
if err != nil {
return
}
for _, share := range linkShares {
share.ID = 0
share.ProjectID = pd.Project.ID
hash, err := utils.CryptoRandomString(40)
if pd.DuplicateShares {
// Permissions / Shares
// To keep it simple(r) we will only copy permissions which are directly used with the project, not the parent
users := []*ProjectUser{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&users)
if err != nil {
return err
return
}
share.Hash = hash
if _, err := s.Insert(share); err != nil {
return err
for _, u := range users {
u.ID = 0
u.ProjectID = pd.Project.ID
if _, err := s.Insert(u); err != nil {
return err
}
}
}
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
log.Debugf("Duplicated user shares from project %d into %d", pd.ProjectID, pd.Project.ID)
teams := []*TeamProject{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&teams)
if err != nil {
return
}
for _, t := range teams {
t.ID = 0
t.ProjectID = pd.Project.ID
if _, err := s.Insert(t); err != nil {
return err
}
}
// Generate new link shares if any are available
linkShares := []*LinkSharing{}
err = s.Where("project_id = ?", pd.ProjectID).Find(&linkShares)
if err != nil {
return
}
for _, share := range linkShares {
share.ID = 0
share.ProjectID = pd.Project.ID
hash, err := utils.CryptoRandomString(40)
if err != nil {
return err
}
share.Hash = hash
if _, err := s.Insert(share); err != nil {
return err
}
}
log.Debugf("Duplicated all link shares from project %d into %d", pd.ProjectID, pd.Project.ID)
}
err = pd.Project.ReadOne(s, doer)
return

View File

@ -25,6 +25,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"xorm.io/xorm"
)
func TestProjectDuplicate(t *testing.T) {
@ -38,6 +39,54 @@ func TestProjectDuplicate(t *testing.T) {
// (non-Unsplash) background would fail with an internal server error
testProjectDuplicate(t, 35, 6)
})
t.Run("shares are not copied by default", func(t *testing.T) {
files.InitTestFileFixtures(t)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 3 has user, team and link shares
u := &user.User{ID: 3}
l := &ProjectDuplicate{ProjectID: 3}
can, err := l.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
require.NoError(t, l.Create(s, u))
assertShareCount(t, s, l.Project.ID, 0, 0, 0)
})
t.Run("shares are copied when duplicate_shares is set", func(t *testing.T) {
files.InitTestFileFixtures(t)
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Project 3 has 2 user shares, 1 team share and 1 link share
u := &user.User{ID: 3}
l := &ProjectDuplicate{ProjectID: 3, DuplicateShares: true}
can, err := l.CanCreate(s, u)
require.NoError(t, err)
assert.True(t, can)
require.NoError(t, l.Create(s, u))
assertShareCount(t, s, l.Project.ID, 2, 1, 1)
})
}
func assertShareCount(t *testing.T, s *xorm.Session, projectID, users, teams, links int64) {
userCount, err := s.Where("project_id = ?", projectID).Count(&ProjectUser{})
require.NoError(t, err)
assert.Equal(t, users, userCount, "unexpected number of user shares")
teamCount, err := s.Where("project_id = ?", projectID).Count(&TeamProject{})
require.NoError(t, err)
assert.Equal(t, teams, teamCount, "unexpected number of team shares")
linkCount, err := s.Where("project_id = ?", projectID).Count(&LinkSharing{})
require.NoError(t, err)
assert.Equal(t, links, linkCount, "unexpected number of link shares")
}
func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
@ -51,7 +100,8 @@ func testProjectDuplicate(t *testing.T, projectID int64, userID int64) {
}
l := &ProjectDuplicate{
ProjectID: projectID,
ProjectID: projectID,
DuplicateShares: true,
}
can, err := l.CanCreate(s, u)
require.NoError(t, err)

View File

@ -49,6 +49,10 @@ type Session struct {
IPAddress string `xorm:"varchar(100)" json:"ip_address" readOnly:"true" doc:"IP address captured from the login request."`
// Whether this is a "remember me" session (controls max refresh lifetime).
IsLongSession bool `xorm:"not null default false" json:"-"`
// Raw OIDC ID token, kept so logout can replay it as id_token_hint. Empty for non-OIDC sessions.
OIDCIDToken string `xorm:"text" json:"-"`
// OIDC provider that created this session, used to find its end-session endpoint at logout.
OIDCProviderKey string `xorm:"varchar(250)" json:"-"`
// When this session was last refreshed.
LastActive time.Time `xorm:"not null" json:"last_active" readOnly:"true" doc:"When this session was last refreshed."`
// When this session was created (login time).
@ -81,9 +85,17 @@ func generateHashedToken() (rawToken, hash string, err error) {
return rawToken, HashSessionToken(rawToken), nil
}
// SessionOIDCData carries the OIDC metadata persisted on a session so an
// RP-Initiated Logout request can be built later. Nil for non-OIDC logins.
type SessionOIDCData struct {
IDToken string
ProviderKey string
}
// CreateSession creates a new session record and generates a refresh token.
// Returns the session with RefreshToken populated (cleartext, shown only once).
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool) (*Session, error) {
// Pass oidc for OpenID Connect logins to persist the logout data; nil otherwise.
func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string, isLongSession bool, oidc *SessionOIDCData) (*Session, error) {
rawToken, hash, err := generateHashedToken()
if err != nil {
return nil, err
@ -98,6 +110,10 @@ func CreateSession(s *xorm.Session, userID int64, deviceInfo, ipAddress string,
IsLongSession: isLongSession,
LastActive: time.Now(),
}
if oidc != nil {
session.OIDCIDToken = oidc.IDToken
session.OIDCProviderKey = oidc.ProviderKey
}
_, err = s.Insert(session)
if err != nil {

View File

@ -1747,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) {
newTask.Done = false
}
var (
checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`)
checklistInputCheckedRegex = regexp.MustCompile(`(<input[^>]*type=["']checkbox["'][^>]*?)\s+checked(?:=["'][^"']*["'])?`)
)
// resetDescriptionChecklist unchecks every checklist item in a TipTap HTML description
// (descriptions are always stored as HTML, never markdown) without touching other content,
// so a recurring task's next occurrence does not inherit checked items.
func resetDescriptionChecklist(description string) string {
description = checklistTiptapCheckedRegex.ReplaceAllString(description, "${1}false${2}")
description = checklistInputCheckedRegex.ReplaceAllString(description, "$1")
return description
}
// This helper function updates the reminders, doneAt, start, end and due dates of the *old* task
// and saves the new values in the newTask object.
// We make a few assumptions here:
@ -1766,6 +1780,11 @@ func updateDone(oldTask *Task, newTask *Task) (updateDoneAt bool) {
setTaskDatesDefault(oldTask, newTask)
}
// A recurring task reopens for its next occurrence, so its checklist starts fresh.
if oldTask.isRepeating() && !newTask.Done {
newTask.Description = resetDescriptionChecklist(newTask.Description)
}
newTask.DoneAt = time.Now()
}

View File

@ -986,6 +986,45 @@ func TestUpdateDone(t *testing.T) {
assert.False(t, newTask.Done)
})
})
t.Run("reset checklist on recurrence", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
const unchecked = `before<ul data-type="taskList"><li data-checked="false" data-type="taskItem"><label><input type="checkbox"><span></span></label><div><p>Item</p></li></ul>after`
oldTask := &Task{
Done: false,
RepeatAfter: 8600,
DueDate: time.Unix(1550000000, 0),
}
newTask := &Task{
Done: true,
Description: checked,
}
updateDone(oldTask, newTask)
assert.False(t, newTask.Done)
assert.True(t, newTask.DueDate.After(oldTask.DueDate))
assert.Equal(t, unchecked, newTask.Description)
})
t.Run("non-recurring description untouched", func(t *testing.T) {
const checked = `before<ul data-type="taskList"><li data-checked="true" data-type="taskItem"><label><input type="checkbox" checked="checked"><span></span></label><div><p>Item</p></li></ul>after`
oldTask := &Task{
Done: false,
RepeatAfter: 0,
RepeatMode: TaskRepeatModeDefault,
DueDate: time.Unix(1550000000, 0),
}
newTask := &Task{
Done: true,
Description: checked,
}
updateDone(oldTask, newTask)
assert.True(t, newTask.Done)
assert.Equal(t, checked, newTask.Description)
})
})
}

View File

@ -112,12 +112,13 @@ type IssuedUserToken struct {
// IssueUserToken creates a session for the user and mints a JWT access token plus
// a refresh token for it. It is the transport-agnostic core both v1 (which writes
// the echo response) and v2 (Huma) call; callers set the refresh cookie and the
// Cache-Control header themselves via WriteUserAuthCookies.
func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool) (*IssuedUserToken, error) {
// Cache-Control header themselves via WriteUserAuthCookies. Pass oidc for
// OpenID Connect logins to store the logout data; nil otherwise.
func IssueUserToken(ctx context.Context, u *user.User, deviceInfo, ipAddress string, long bool, oidc *models.SessionOIDCData) (*IssuedUserToken, error) {
s := db.NewSession()
defer s.Close()
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long)
session, err := models.CreateSession(s, u.ID, deviceInfo, ipAddress, long, oidc)
if err != nil {
_ = s.Rollback()
return nil, err
@ -161,8 +162,9 @@ func WriteUserAuthCookies(c *echo.Context, token *IssuedUserToken) {
}
// NewUserAuthTokenResponse creates a new user auth token response from a user object.
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool) error {
token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long)
// Pass oidc for OpenID Connect logins to store the logout data; nil otherwise.
func NewUserAuthTokenResponse(u *user.User, c *echo.Context, long bool, oidc *models.SessionOIDCData) error {
token, err := IssueUserToken(c.Request().Context(), u, c.Request().UserAgent(), c.RealIP(), long, oidc)
if err != nil {
return err
}

View File

@ -114,7 +114,7 @@ func exchangeAuthorizationCode(ctx context.Context, req *TokenRequest, deviceInf
}
// Create a session (reuses existing session infrastructure)
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false)
session, err := models.CreateSession(s, oauthCode.UserID, deviceInfo, ipAddress, false, nil)
if err != nil {
_ = s.Rollback()
return nil, err

View File

@ -0,0 +1,110 @@
// 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 <https://www.gnu.org/licenses/>.
package openid
import (
"net/url"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/log"
"code.vikunja.io/api/pkg/models"
)
// EndSessionEndpoint returns the provider's RP-Initiated Logout endpoint
// (discovery's end_session_endpoint, cached at init), falling back to the static
// logouturl. Never triggers discovery so logout stays responsive when the OP is
// unreachable.
func (p *Provider) EndSessionEndpoint() string {
if p.EndSessionURL != "" {
return p.EndSessionURL
}
return p.LogoutURL
}
// discoveredEndSessionEndpoint reads end_session_endpoint from the discovery
// document already cached on the *oidc.Provider, so Claims unmarshals in memory
// without a request.
func (p *Provider) discoveredEndSessionEndpoint() string {
if p.openIDProvider == nil {
return ""
}
var meta struct {
EndSessionEndpoint string `json:"end_session_endpoint"`
}
if err := p.openIDProvider.Claims(&meta); err != nil {
log.Debugf("Could not read end_session_endpoint for provider %s: %v", p.Key, err)
return ""
}
return meta.EndSessionEndpoint
}
// BuildEndSessionURL builds an OpenID Connect RP-Initiated Logout 1.0 request URL
// (id_token_hint + post_logout_redirect_uri + client_id; see RP-Initiated Logout
// 1.0 §2). post_logout_redirect_uri defaults to service.publicurl, and the OP
// only honors it when id_token_hint is present. Returns "" when neither an
// end_session_endpoint nor a static logouturl is configured.
func BuildEndSessionURL(providerKey string, oidc *models.SessionOIDCData) (string, error) {
// GetProvider would trigger OIDC discovery (a live HTTP GET that blocks when
// the OP is down); the cached static fields are all logout needs.
provider, err := getCachedProvider(providerKey)
if err != nil {
return "", err
}
if provider == nil {
return "", nil
}
idToken := ""
if oidc != nil {
idToken = oidc.IDToken
}
return buildEndSessionURL(
provider.EndSessionEndpoint(),
provider.ClientID,
idToken,
config.ServicePublicURL.GetString(),
)
}
// buildEndSessionURL appends the logout query params onto endpoint, omitting
// empty ones, and returns "" for an empty endpoint.
func buildEndSessionURL(endpoint, clientID, idToken, postLogoutRedirectURI string) (string, error) {
if endpoint == "" {
return "", nil
}
u, err := url.Parse(endpoint)
if err != nil {
return "", err
}
q := u.Query()
if clientID != "" {
q.Set("client_id", clientID)
}
if idToken != "" {
q.Set("id_token_hint", idToken)
}
if postLogoutRedirectURI != "" {
q.Set("post_logout_redirect_uri", postLogoutRedirectURI)
}
u.RawQuery = q.Encode()
return u.String(), nil
}

View File

@ -0,0 +1,234 @@
// 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 <https://www.gnu.org/licenses/>.
package openid
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/keyvalue"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newMockOIDCServerWithEndSession publishes a discovery document with an
// end_session_endpoint.
func newMockOIDCServerWithEndSession() *httptest.Server {
var server *httptest.Server
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) {
discovery := map[string]interface{}{
"issuer": server.URL,
"authorization_endpoint": server.URL + "/auth",
"token_endpoint": server.URL + "/token",
"jwks_uri": server.URL + "/jwks",
"end_session_endpoint": server.URL + "/logout",
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(discovery)
})
server = httptest.NewServer(mux)
return server
}
func TestBuildEndSessionURLAssembly(t *testing.T) {
t.Run("all params", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "https", u.Scheme)
assert.Equal(t, "op.example.com", u.Host)
assert.Equal(t, "/logout", u.Path)
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "my-client", q.Get("client_id"))
})
t.Run("preserves existing endpoint query params", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout?foo=bar", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "bar", q.Get("foo"))
assert.Equal(t, "the-id-token", q.Get("id_token_hint"))
})
t.Run("omits id_token_hint when no token", func(t *testing.T) {
got, err := buildEndSessionURL("https://op.example.com/logout", "my-client", "", "https://vikunja.example.com/")
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.False(t, q.Has("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "my-client", q.Get("client_id"))
})
t.Run("empty endpoint returns empty", func(t *testing.T) {
got, err := buildEndSessionURL("", "my-client", "the-id-token", "https://vikunja.example.com/")
require.NoError(t, err)
assert.Empty(t, got)
})
}
func TestBuildEndSessionURLFromDiscovery(t *testing.T) {
defer CleanupSavedOpenIDProviders()
server := newMockOIDCServerWithEndSession()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.ServicePublicURL.Set("https://vikunja.example.com/")
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
IDToken: "raw-id-token",
ProviderKey: "provider1",
})
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, server.URL+"/logout", u.Scheme+"://"+u.Host+u.Path)
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "client1", q.Get("client_id"))
}
func TestBuildEndSessionURLFromCachedProviderWithoutLiveObject(t *testing.T) {
defer CleanupSavedOpenIDProviders()
config.AuthOpenIDEnabled.Set(true)
config.ServicePublicURL.Set("https://vikunja.example.com/")
// Seed only the cached static fields (no live openIDProvider), mimicking a
// provider restored from keyvalue whose OP is unreachable.
_ = keyvalue.Del("openid_providers")
require.NoError(t, keyvalue.Put("openid_provider_provider1", &Provider{
Key: "provider1",
ClientID: "client1",
EndSessionURL: "https://op.example.com/end-session",
}))
got, err := BuildEndSessionURL("provider1", &models.SessionOIDCData{
IDToken: "raw-id-token",
ProviderKey: "provider1",
})
require.NoError(t, err)
u, err := url.Parse(got)
require.NoError(t, err)
q := u.Query()
assert.Equal(t, "https://op.example.com/end-session", u.Scheme+"://"+u.Host+u.Path)
assert.Equal(t, "raw-id-token", q.Get("id_token_hint"))
assert.Equal(t, "https://vikunja.example.com/", q.Get("post_logout_redirect_uri"))
assert.Equal(t, "client1", q.Get("client_id"))
}
func TestEndSessionEndpointUsesCachedURLWithoutDiscovery(t *testing.T) {
// A nil openIDProvider models a provider restored from cache (or an
// unreachable OP): EndSessionEndpoint must answer from the cached URL.
p := &Provider{
Key: "provider1",
LogoutURL: "https://op.example.com/static-logout",
EndSessionURL: "https://op.example.com/end-session",
}
assert.Equal(t, "https://op.example.com/end-session", p.EndSessionEndpoint())
}
func TestEndSessionEndpointFallsBackToLogoutURLWhenNotCached(t *testing.T) {
p := &Provider{
Key: "provider1",
LogoutURL: "https://op.example.com/static-logout",
}
assert.Equal(t, "https://op.example.com/static-logout", p.EndSessionEndpoint())
}
func TestEndSessionEndpointCachedFromDiscoveryOnInit(t *testing.T) {
defer CleanupSavedOpenIDProviders()
server := newMockOIDCServerWithEndSession()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
provider, err := GetProvider("provider1")
require.NoError(t, err)
require.NotNil(t, provider)
assert.Equal(t, server.URL+"/logout", provider.EndSessionURL)
assert.Equal(t, server.URL+"/logout", provider.EndSessionEndpoint())
}
func TestEndSessionEndpointFallsBackToStaticLogoutURL(t *testing.T) {
defer CleanupSavedOpenIDProviders()
// newMockOIDCServer publishes no end_session_endpoint, forcing the logouturl fallback.
server := newMockOIDCServer()
defer server.Close()
config.AuthOpenIDEnabled.Set(true)
config.AuthOpenIDProviders.Set(map[string]interface{}{
"provider1": map[string]interface{}{
"name": "Provider One",
"authurl": server.URL,
"clientid": "client1",
"clientsecret": "secret1",
"logouturl": "https://op.example.com/static-logout",
},
})
_ = keyvalue.Del("openid_providers")
_ = keyvalue.Del("openid_provider_provider1")
provider, err := GetProvider("provider1")
require.NoError(t, err)
require.NotNil(t, provider)
assert.Equal(t, "https://op.example.com/static-logout", provider.EndSessionEndpoint())
}

View File

@ -69,8 +69,12 @@ type Provider struct {
ForceUserInfo bool `json:"force_user_info"`
RequireAvailability bool `json:"-"`
ClientSecret string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
// RP-Initiated Logout endpoint, cached at init so logout never fetches.
// Exported so it survives the gob keyvalue round-trip (gob skips unexported
// fields like openIDProvider); json:"-" keeps it out of /info.
EndSessionURL string `json:"-"`
openIDProvider *oidc.Provider
Oauth2Config *oauth2.Config `json:"-"`
}
type claims struct {
@ -173,7 +177,7 @@ func HandleCallback(c *echo.Context) error {
return &models.ErrOpenIDBadRequest{Message: "Bad data"}
}
u, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
u, oidcData, err := AuthenticateCallback(c.Request().Context(), cb, c.Param("provider"))
if err != nil {
var detailedErr *models.ErrOpenIDBadRequestWithDetails
if errors.As(err, &detailedErr) {
@ -186,7 +190,7 @@ func HandleCallback(c *echo.Context) error {
}
// Create token
return auth.NewUserAuthTokenResponse(u, c, false)
return auth.NewUserAuthTokenResponse(u, c, false, oidcData)
}
// AuthenticateCallback resolves an OpenID Connect callback to an authenticated
@ -196,18 +200,24 @@ func HandleCallback(c *echo.Context) error {
// handler and the v2 Huma handler; the caller issues the auth token. The
// ErrOpenIDBadRequestWithDetails error keeps its provider detail so v1 can render
// its bespoke body and v2 can map it to RFC 9457.
func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, error) {
func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string) (*user.User, *models.SessionOIDCData, error) {
// ctx is threaded through only to dispatch the login event; the OIDC token
// exchange, claim verification and user/avatar sync run on their own
// background contexts, exactly as the v1 callback always did.
provider, oauthToken, idToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
provider, oauthToken, idToken, rawIDToken, err := exchangeOidcTokens(cb, providerKey) //nolint:contextcheck
if err != nil {
return nil, err
return nil, nil, err
}
// Stored so logout can replay it as id_token_hint in an RP-Initiated Logout.
oidcData := &models.SessionOIDCData{
IDToken: rawIDToken,
ProviderKey: providerKey,
}
cl, err := getClaims(provider, oauthToken, idToken) //nolint:contextcheck
if err != nil {
return nil, err
return nil, nil, err
}
s := db.NewSession()
@ -221,16 +231,16 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string)
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
return nil, err
return nil, nil, err
}
if u.Status == user.StatusDisabled {
_ = s.Rollback()
return nil, &user.ErrAccountDisabled{UserID: u.ID}
return nil, nil, &user.ErrAccountDisabled{UserID: u.ID}
}
if u.Status == user.StatusAccountLocked {
_ = s.Rollback()
return nil, &user.ErrAccountLocked{UserID: u.ID}
return nil, nil, &user.ErrAccountLocked{UserID: u.ID}
}
// Must run before team sync so a failed 2FA attempt cannot mutate team
@ -247,26 +257,26 @@ func AuthenticateCallback(ctx context.Context, cb *Callback, providerKey string)
if user.IsErrInvalidTOTPPasscode(err) {
user.HandleFailedTOTPAuth(u)
}
return nil, err
return nil, nil, err
}
teamData := getTeamDataFromToken(cl.VikunjaGroups, provider)
err = models.SyncExternalTeamsForUser(s, u, teamData, idToken.Issuer, provider.Name)
if err != nil {
return nil, err
return nil, nil, err
}
err = s.Commit()
if err != nil {
_ = s.Rollback()
log.Errorf("Error creating new team for provider %s: %v", provider.Name, err)
return nil, err
return nil, nil, err
}
events.DispatchPending(ctx, s)
return u, nil
return u, oidcData, nil
}
func getTeamDataFromToken(groups []map[string]interface{}, provider *Provider) (teamData []*models.Team) {
@ -367,6 +377,46 @@ func syncUserAvatarFromOpenID(s *xorm.Session, u *user.User, pictureURL string)
return nil
}
// fallbackSearchUsers builds the ordered list of local-user lookups used to link an OIDC
// login to an existing account when the provider has email and/or username fallback enabled.
// GetUserWithEmail ANDs all non-zero fields, so the email (when set) is combined with each
// username candidate.
func fallbackSearchUsers(cl *claims, provider *Provider, idToken *oidc.IDToken) []*user.User {
fallbackEmail := ""
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account.
// Discouraged for untrusted providers where someone can set email without verification.
// Note: mapping on email prevents auto-updating the user email.
fallbackEmail = cl.Email
}
// Try the subject first (keeps working for IdPs where sub == username), then the
// preferred_username. The latter lets providers with an opaque sub (e.g. a random
// UUID, like PocketID) still link to an existing local account.
var searches []*user.User
if provider.UsernameFallback {
// Skip empty username candidates: GetUserWithEmail ANDs only non-zero fields, so a
// {Issuer, Username:"", Email:""} would degenerate to an issuer-only lookup and link
// an arbitrary local user. idToken.Subject is non-empty per OIDC, but guard anyway.
if idToken.Subject != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: idToken.Subject, Email: fallbackEmail})
}
preferred := strings.ReplaceAll(cl.PreferredUsername, " ", "-")
if preferred != "" && preferred != idToken.Subject {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Username: preferred, Email: fallbackEmail})
}
}
// EmailFallback without UsernameFallback: a single email-only lookup (the caller only
// runs this when at least one fallback is enabled, so EmailFallback is guaranteed here).
// Only add it when there is a real email — an empty email would degenerate to an
// issuer-only lookup and link an arbitrary local user.
if len(searches) == 0 && cl.Email != "" {
searches = append(searches, &user.User{Issuer: user.IssuerLocal, Email: cl.Email})
}
return searches
}
func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *oidc.IDToken) (u *user.User, err error) {
// set defaults
@ -392,33 +442,21 @@ func getOrCreateUser(s *xorm.Session, cl *claims, provider *Provider, idToken *o
if !alreadyCreatedFromIssuer && (provider.EmailFallback || provider.UsernameFallback) {
// try finding the user on fallback mappingproperties
// try finding the user on fallback mapping properties
for _, searchUser := range fallbackSearchUsers(cl, provider, idToken) {
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
searchUser := &user.User{
Issuer: user.IssuerLocal,
}
if provider.UsernameFallback {
// Match oidc subject on username as each is unique identifier in its own referential
// Discouraged if multiple account providers are used.
searchUser.Username = idToken.Subject
}
if provider.EmailFallback {
// Used alone, allow for someone to connect from various provider to the same account
// Discouraged for untrusted provider where someone can set email without verification
// Note : mapping on email prevent from auto-updating user email
searchUser.Email = cl.Email
}
// Check if the user exists for the given fallback matching options
u, err = user.GetUserWithEmail(s, searchUser)
if err != nil && !user.IsErrUserDoesNotExist(err) && !user.IsErrUserStatusError(err) {
return nil, err
}
fallbackMatchFound = err == nil || user.IsErrUserStatusError(err)
// Same as above: disabled/locked user found via fallback — return early.
if fallbackMatchFound && user.IsErrUserStatusError(err) {
return u, nil
// Same as above: disabled/locked user found via fallback — return early.
if fallbackMatchFound && user.IsErrUserStatusError(err) {
return u, nil
}
if fallbackMatchFound {
break
}
}
}
@ -543,13 +581,13 @@ func getClaims(provider *Provider, oauth2Token *oauth2.Token, idToken *oidc.IDTo
// and verifies the returned ID token. It takes an already-bound Callback so it
// can be shared by the v1 echo handler (which binds from the request) and the v2
// Huma handler (which binds via its typed body).
func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, error) {
func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.Token, *oidc.IDToken, string, error) {
provider, err := GetProvider(providerKey)
if err != nil {
return nil, nil, nil, err
return nil, nil, nil, "", err
}
if provider == nil {
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Provider does not exist"}
}
log.Debugf("Trying to authenticate user using provider: %s", provider.Key)
@ -565,25 +603,25 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To
if err := json.Unmarshal(rerr.Body, &details); err != nil {
log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
log.Debugf("Raw token value is %s", rerr.Body)
return nil, nil, nil, err
return nil, nil, nil, "", err
}
log.Errorf("Error retrieving token: %s", err)
log.Debugf("Raw token value is %s", rerr.Body)
return nil, nil, nil, &models.ErrOpenIDBadRequestWithDetails{
return nil, nil, nil, "", &models.ErrOpenIDBadRequestWithDetails{
Message: "Could not authenticate against third party.",
Details: details,
}
}
return nil, nil, nil, err
return nil, nil, nil, "", err
}
// Extract the ID Token from OAuth2 token.
rawIDToken, ok := oauth2Token.Extra("id_token").(string)
if !ok {
log.Debugf("Could not get id_token, raw token is %v", oauth2Token)
return nil, nil, nil, &models.ErrOpenIDBadRequest{Message: "Missing token"}
return nil, nil, nil, "", &models.ErrOpenIDBadRequest{Message: "Missing token"}
}
verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})
@ -592,8 +630,8 @@ func exchangeOidcTokens(cb *Callback, providerKey string) (*Provider, *oauth2.To
idToken, err := verifier.Verify(context.Background(), rawIDToken)
if err != nil {
log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
return nil, nil, nil, err
return nil, nil, nil, "", err
}
return provider, oauth2Token, idToken, nil
return provider, oauth2Token, idToken, rawIDToken, nil
}

View File

@ -254,11 +254,61 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on preferred_username when sub differs", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "user11",
}
provider := &Provider{
UsernameFallback: true,
}
// PocketID-style: the subject is an opaque UUID that does not match any local username.
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "c0ffee00-dead-beef-cafe-000000000011"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
err = s.Commit()
require.NoError(t, err)
assert.Equal(t, "user11", u.Username, "should link to the local user matching preferred_username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// No duplicate user must be created for the opaque subject.
db.AssertMissing(t, "users", map[string]interface{}{
"subject": idToken.Subject,
})
})
t.Run("ProviderFallback: Falls back to sub when preferred_username is empty", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
cl := &claims{
PreferredUsername: "",
}
provider := &Provider{
UsernameFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "user11"}
u, err := getOrCreateUser(s, cl, provider, idToken)
require.NoError(t, err)
assert.Equal(t, idToken.Subject, u.Username, "subject should match username")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
})
t.Run("ProviderFallback: Match to existing local user on email", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
cl := &claims{
Email: "user11@example.com",
}
@ -272,6 +322,42 @@ func TestGetOrCreateUser(t *testing.T) {
assert.Equal(t, cl.Email, u.Email, "email should match")
assert.Equal(t, user.IssuerLocal, u.Issuer, "User should be a local one")
assert.Equal(t, 11, int(u.ID), "user id 11 expected")
// The email-only fallback must link the existing user, not create a duplicate.
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no new user should have been created")
})
t.Run("ProviderFallback: empty email claim does not link to an arbitrary local user", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
usersBefore, err := s.Count(&user.User{})
require.NoError(t, err)
// EmailFallback on, no username fallback, and the IdP sent no email claim. The
// email-only search must not degenerate to an issuer-only lookup matching an
// arbitrary local user. With no email there is nothing safe to match on, so the
// flow falls through to user creation (which then errors because an email is
// required) rather than silently linking an existing local account.
cl := &claims{
Email: "",
PreferredUsername: "brandNewOidcUser",
}
provider := &Provider{
EmailFallback: true,
}
idToken := &oidc.IDToken{Issuer: "https://some.issuer", Subject: "opaque-subject-no-email"}
u, err := getOrCreateUser(s, cl, provider, idToken)
// Must not have linked an existing local user.
require.Error(t, err, "an empty email must not silently link an existing local user")
assert.Nil(t, u, "no existing local user should be returned for an empty email claim")
usersAfter, err := s.Count(&user.User{})
require.NoError(t, err)
assert.Equal(t, usersBefore, usersAfter, "no user should have been linked or created from an empty email claim")
})
t.Run("ProviderFallback: Match to existing local user on username and email", func(t *testing.T) {

View File

@ -180,6 +180,29 @@ func GetProvider(key string) (provider *Provider, err error) {
return
}
// getCachedProvider returns the provider from keyvalue without re-establishing
// the live OIDC connection, so the logout path never blocks on an unreachable OP.
func getCachedProvider(key string) (provider *Provider, err error) {
provider = &Provider{}
exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
if !exists {
_, err = GetAllProviders() // This will put all providers in cache
if err != nil {
return nil, err
}
_, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
if err != nil {
return nil, err
}
}
return provider, nil
}
// parseBoolField reads a boolean-valued config field from a provider map,
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
// the GetConfigValueFromFile path, which always return strings). Missing or
@ -313,6 +336,8 @@ func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provid
provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL
provider.EndSessionURL = provider.discoveredEndSessionEndpoint()
return
}

View File

@ -22,8 +22,10 @@ import (
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"code.vikunja.io/api/pkg/config"
@ -253,6 +255,72 @@ func parseDate(dateString string) (date time.Time, err error) {
return date, err
}
// Matching the existing migration importers, months are treated as 30 days and years as 365.
const (
secondsPerDay int64 = 60 * 60 * 24
secondsPerWeek = secondsPerDay * 7
secondsPerMonth = secondsPerDay * 30
secondsPerYear = secondsPerDay * 365
)
var repeatUnitSeconds = map[string]int64{
"day": secondsPerDay,
"week": secondsPerWeek,
"month": secondsPerMonth,
"year": secondsPerYear,
}
var (
todoistRepeatRegex = regexp.MustCompile(`^(?:every\s+)?(?:(\d+)\s+|(other)\s+)?(day|week|month|year)s?$`)
todoistRepeatTimeRegex = regexp.MustCompile(`\s+(?:at|@)\s+.*$`)
)
// parseTodoistRepeat translates Todoist's recurrence into a repeat interval in seconds.
// Todoist exposes recurrence only as free text (e.g. "every 3 weeks"), so we parse the
// common, unambiguous interval phrases. Patterns we can't represent (specific weekdays,
// days of the month, non-English strings) return 0, leaving the task non-repeating. Only
// the cadence is kept - the due date already anchors the actual day and time.
func parseTodoistRepeat(due *dueDate) int64 {
if due == nil || !due.IsRecurring {
return 0
}
s := strings.ToLower(strings.TrimSpace(due.String))
// The time of day is already on the due date, drop it so "every day at 9am" still matches.
s = todoistRepeatTimeRegex.ReplaceAllString(s, "")
switch s {
case "daily":
return secondsPerDay
case "weekly":
return secondsPerWeek
case "monthly":
return secondsPerMonth
case "yearly", "annually":
return secondsPerYear
}
matches := todoistRepeatRegex.FindStringSubmatch(s)
if matches == nil {
log.Debugf("[Todoist Migration] Could not parse recurrence %q, leaving task non-repeating", due.String)
return 0
}
interval := int64(1)
switch {
case matches[1] != "":
n, err := strconv.ParseInt(matches[1], 10, 64)
if err != nil || n < 1 {
return 0
}
interval = n
case matches[2] == "other":
interval = 2
}
return interval * repeatUnitSeconds[matches[3]]
}
func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVikunjaHierachie []*models.ProjectWithTasksAndBuckets, err error) {
var pseudoParentID int64 = 1
@ -358,6 +426,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[string]*doneItem) (fullVi
return nil, err
}
task.DueDate = dueDate.In(config.GetTimeZone())
task.RepeatAfter = parseTodoistRepeat(i.Due)
}
// Put all labels together from earlier

View File

@ -651,3 +651,47 @@ func TestConvertTodoistToVikunja(t *testing.T) {
t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff)
}
}
func TestParseTodoistRepeat(t *testing.T) {
tests := []struct {
name string
due *dueDate
want int64
}{
{name: "nil due", due: nil, want: 0},
{name: "not recurring", due: &dueDate{String: "every day", IsRecurring: false}, want: 0},
{name: "every day", due: &dueDate{String: "every day", IsRecurring: true}, want: secondsPerDay},
{name: "daily", due: &dueDate{String: "daily", IsRecurring: true}, want: secondsPerDay},
{name: "every other day", due: &dueDate{String: "every other day", IsRecurring: true}, want: 2 * secondsPerDay},
{name: "every 3 days", due: &dueDate{String: "every 3 days", IsRecurring: true}, want: 3 * secondsPerDay},
{name: "every week", due: &dueDate{String: "every week", IsRecurring: true}, want: secondsPerWeek},
{name: "weekly", due: &dueDate{String: "weekly", IsRecurring: true}, want: secondsPerWeek},
{name: "every other week", due: &dueDate{String: "every other week", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every 2 weeks", due: &dueDate{String: "every 2 weeks", IsRecurring: true}, want: 2 * secondsPerWeek},
{name: "every month", due: &dueDate{String: "every month", IsRecurring: true}, want: secondsPerMonth},
{name: "monthly", due: &dueDate{String: "monthly", IsRecurring: true}, want: secondsPerMonth},
{name: "every 3 months", due: &dueDate{String: "every 3 months", IsRecurring: true}, want: 3 * secondsPerMonth},
{name: "every year", due: &dueDate{String: "every year", IsRecurring: true}, want: secondsPerYear},
{name: "yearly", due: &dueDate{String: "yearly", IsRecurring: true}, want: secondsPerYear},
{name: "annually", due: &dueDate{String: "annually", IsRecurring: true}, want: secondsPerYear},
{name: "case insensitive", due: &dueDate{String: "Every Day", IsRecurring: true}, want: secondsPerDay},
{name: "time of day stripped", due: &dueDate{String: "every day at 9am", IsRecurring: true}, want: secondsPerDay},
// Tier 1 doesn't understand these, so the task stays non-repeating.
{name: "specific weekday", due: &dueDate{String: "every monday", IsRecurring: true}, want: 0},
{name: "day of month", due: &dueDate{String: "every 27th", IsRecurring: true}, want: 0},
{name: "non-english", due: &dueDate{String: "cada día", IsRecurring: true}, want: 0},
{name: "gibberish", due: &dueDate{String: "whenever", IsRecurring: true}, want: 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, parseTodoistRepeat(tt.due))
})
}
}

View File

@ -27,6 +27,7 @@ import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/auth"
"code.vikunja.io/api/pkg/modules/auth/ldap"
"code.vikunja.io/api/pkg/modules/auth/openid"
"code.vikunja.io/api/pkg/modules/keyvalue"
"code.vikunja.io/api/pkg/user"
@ -192,24 +193,53 @@ func enforceLoginTOTP(s *xorm.Session, u *user.User, passcode string) error {
// API token or a link share), matching v1. Shared by v1 and v2; the caller is
// responsible for clearing the refresh cookie.
func DeleteSession(sid string) error {
_, err := LogoutSession(sid)
return err
}
// LogoutSession deletes the session and returns its OIDC RP-Initiated Logout URL
// for the frontend to redirect to (empty for non-OIDC sessions or when no logout
// endpoint is configured). An empty sid is a no-op. The caller clears the refresh
// cookie.
func LogoutSession(sid string) (endSessionURL string, err error) {
if sid == "" {
return nil
return "", nil
}
s := db.NewSession()
defer s.Close()
// Read before deleting so the stored id_token survives for the logout URL.
// A missing session just means there is nothing to log out.
session, err := models.GetSessionByID(s, sid)
if err != nil && !models.IsErrSessionNotFound(err) {
_ = s.Rollback()
return "", err
}
if session != nil && session.OIDCProviderKey != "" {
url, buildErr := openid.BuildEndSessionURL(session.OIDCProviderKey, &models.SessionOIDCData{
IDToken: session.OIDCIDToken,
ProviderKey: session.OIDCProviderKey,
})
if buildErr != nil {
// A failed URL build must not block logout; the session is still deleted below.
log.Errorf("Could not build OIDC end-session URL for session %s: %v", sid, buildErr)
} else {
endSessionURL = url
}
}
if _, err := s.Where("id = ?", sid).Delete(&models.Session{}); err != nil {
_ = s.Rollback()
return err
return "", err
}
if err := s.Commit(); err != nil {
_ = s.Rollback()
return err
return "", err
}
return nil
return endSessionURL, nil
}
// ResetPassword resets a user's password from a previously issued reset token

View File

@ -54,6 +54,8 @@ type VikunjaInfos struct {
PublicTeamsEnabled bool `json:"public_teams_enabled" doc:"Whether public teams are enabled."`
AllowIconChanges bool `json:"allow_icon_changes" doc:"Whether users may change project icons."`
EnabledProFeatures []license.Feature `json:"enabled_pro_features" doc:"The licensed pro features enabled on this instance."`
// ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.
ConcurrentWrites bool `json:"concurrent_writes" doc:"Whether the configured database supports concurrent writes. False on SQLite; clients should serialize batched writes when this is false."`
}
// AuthInfo describes the authentication methods enabled on this instance.
@ -106,6 +108,7 @@ func BuildInfo() VikunjaInfos {
WebhooksEnabled: config.WebhooksEnabled.GetBool(),
PublicTeamsEnabled: config.ServiceEnablePublicTeams.GetBool(),
AllowIconChanges: config.ServiceAllowIconChanges.GetBool(),
ConcurrentWrites: config.DatabaseType.GetString() != "sqlite",
EnabledProFeatures: license.EnabledProFeatures(),
AvailableMigrators: []string{
(&vikunja_file.FileMigrator{}).Name(),

View File

@ -56,7 +56,7 @@ func Login(c *echo.Context) (err error) {
}
// Create token
return auth.NewUserAuthTokenResponse(user, c, u.LongToken)
return auth.NewUserAuthTokenResponse(user, c, u.LongToken, nil)
}
// RenewToken renews a link share token only. User tokens must use
@ -150,12 +150,18 @@ func RefreshToken(c *echo.Context) (err error) {
return c.JSON(http.StatusOK, auth.Token{Token: result.AccessToken})
}
type LogoutResponse struct {
Message string `json:"message"`
// RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.
OIDCLogoutURL string `json:"oidc_logout_url,omitempty"`
}
// Logout deletes the current session from the server.
// @Summary Logout
// @Description Destroys the current session and clears the refresh token cookie.
// @Description Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.
// @tags auth
// @Produce json
// @Success 200 {object} models.Message "Successfully logged out."
// @Success 200 {object} v1.LogoutResponse "Successfully logged out."
// @Router /user/logout [post]
func Logout(c *echo.Context) (err error) {
auth.ClearRefreshTokenCookie(c)
@ -177,7 +183,8 @@ func Logout(c *echo.Context) (err error) {
}
}
if err := shared.DeleteSession(sid); err != nil {
oidcLogoutURL, err := shared.LogoutSession(sid)
if err != nil {
return err
}
@ -187,5 +194,8 @@ func Logout(c *echo.Context) (err error) {
}
}
return c.JSON(http.StatusOK, models.Message{Message: "Successfully logged out."})
return c.JSON(http.StatusOK, LogoutResponse{
Message: "Successfully logged out.",
OIDCLogoutURL: oidcLogoutURL,
})
}

View File

@ -45,7 +45,8 @@ type authTokenBody struct {
// logoutBody confirms a successful logout.
type logoutBody struct {
Body struct {
Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
Message string `json:"message" readOnly:"true" doc:"A human-readable confirmation message."`
OIDCLogoutURL string `json:"oidc_logout_url,omitempty" readOnly:"true" doc:"RP-Initiated Logout URL to redirect to for OpenID Connect sessions; empty otherwise."`
}
}
@ -86,7 +87,7 @@ func authLogin(ctx context.Context, in *struct{ Body user.Login }) (*authTokenBo
}
deviceInfo, ipAddress := requestClientInfo(ctx)
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken)
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, in.Body.LongToken, nil)
if err != nil {
return nil, translateDomainError(err)
}
@ -107,12 +108,14 @@ func authLogout(ctx context.Context, _ *struct{}) (*logoutBody, error) {
sid = auth.SessionIDFromContext(ec)
}
if err := shared.DeleteSession(sid); err != nil {
oidcLogoutURL, err := shared.LogoutSession(sid) //nolint:contextcheck // OIDC provider discovery resolves from a cached, context-less map and runs on its own background context, like the OIDC callback.
if err != nil {
return nil, translateDomainError(err)
}
out := &logoutBody{}
out.Body.Message = "Successfully logged out."
out.Body.OIDCLogoutURL = oidcLogoutURL
return out, nil
}

View File

@ -55,14 +55,14 @@ func authOpenIDCallback(ctx context.Context, in *struct {
Provider string `path:"provider" doc:"The OpenID Connect provider key as returned by the /info endpoint."`
Body openid.Callback `doc:"The provider callback, carrying the authorization code."`
}) (*authTokenBody, error) {
u, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback.
u, oidcData, err := openid.AuthenticateCallback(ctx, &in.Body, in.Provider) //nolint:contextcheck // resolves providers from a cached, context-less map and runs OIDC discovery on its own background context, like the v1 callback.
if err != nil {
return nil, translateOpenIDError(err)
}
deviceInfo, ipAddress := requestClientInfo(ctx)
// OIDC logins are not "remember me" sessions; v1 always issues a short one.
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false)
token, err := auth.IssueUserToken(ctx, u, deviceInfo, ipAddress, false, oidcData)
if err != nil {
return nil, translateDomainError(err)
}

View File

@ -0,0 +1,155 @@
// 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 <https://www.gnu.org/licenses/>.
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"
)
// bucketListBody is the list-response envelope. models.Bucket.ReadAll returns
// []*models.Bucket, so that's the element type.
type bucketListBody struct {
Body Paginated[*models.Bucket]
}
// RegisterBucketRoutes wires the nested kanban-bucket CRUD onto the Huma API.
// Buckets live under /projects/{project}/views/{view}/buckets; every operation
// binds {project} → ProjectID and {view} → ProjectViewID, the write operations
// additionally {bucket} → ID. There is intentionally no read-one route
// (mirroring v1: the Bucket model has no ReadOne/CanRead), so AutoPatch
// synthesises no PATCH either.
func RegisterBucketRoutes(api huma.API) {
tags := []string{"projects"}
Register(api, huma.Operation{
OperationID: "buckets-list",
Summary: "List the buckets of a kanban view",
Description: "Returns all kanban buckets of a project view, ordered by position. Requires read access to the project. The list is not paginated by the server but is returned in the standard list envelope. To get the buckets together with their tasks, use the buckets/tasks endpoint instead.",
Method: http.MethodGet,
Path: "/projects/{project}/views/{view}/buckets",
Tags: tags,
}, bucketsList)
Register(api, huma.Operation{
OperationID: "buckets-create",
Summary: "Create a bucket in a kanban view",
Description: "Creates a kanban bucket in the given project view. The project and view come from the URL, not the body. Requires write access to the project.",
Method: http.MethodPost,
Path: "/projects/{project}/views/{view}/buckets",
Tags: tags,
}, bucketsCreate)
Register(api, huma.Operation{
OperationID: "buckets-update",
Summary: "Update a bucket of a kanban view",
Description: "Replaces a kanban bucket's title, limit and position. The bucket is identified by the URL, which also scopes it to the project and view. Requires write access to the project.",
Method: http.MethodPut,
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
Tags: tags,
}, bucketsUpdate)
Register(api, huma.Operation{
OperationID: "buckets-delete",
Summary: "Delete a bucket of a kanban view",
Description: "Deletes a kanban bucket and moves its tasks to the view's default bucket; no tasks are deleted. You cannot delete the last bucket of a view (rejected with 412). Requires write access to the project.",
Method: http.MethodDelete,
Path: "/projects/{project}/views/{view}/buckets/{bucket}",
Tags: tags,
}, bucketsDelete)
}
func init() { AddRouteRegistrar(RegisterBucketRoutes) }
func bucketsList(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
ListParams
}) (*bucketListBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
result, _, total, err := handler.DoReadAll(ctx, &models.Bucket{ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a, in.Q, in.Page, in.PerPage)
if err != nil {
return nil, translateDomainError(err)
}
buckets, ok := result.([]*models.Bucket)
if !ok {
return nil, fmt.Errorf("buckets.ReadAll returned unexpected type %T (expected []*models.Bucket)", result)
}
return &bucketListBody{Body: NewPaginated(buckets, total, in.Page, in.PerPage)}, nil
}
func bucketsCreate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
Body models.Bucket
}) (*singleBody[models.Bucket], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
b := &in.Body
b.ProjectID = in.ProjectID // URL wins over body
b.ProjectViewID = in.ViewID // URL wins over body
if err := handler.DoCreate(ctx, b, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Bucket]{Body: b}, nil
}
func bucketsUpdate(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
BucketID int64 `path:"bucket"`
Body models.Bucket
}) (*singleBody[models.Bucket], error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
b := &in.Body
b.ID = in.BucketID // URL wins over body
b.ProjectID = in.ProjectID // URL wins over body
b.ProjectViewID = in.ViewID // URL wins over body
if err := handler.DoUpdate(ctx, b, a); err != nil {
return nil, translateDomainError(err)
}
return &singleBody[models.Bucket]{Body: b}, nil
}
func bucketsDelete(ctx context.Context, in *struct {
ProjectID int64 `path:"project"`
ViewID int64 `path:"view"`
BucketID int64 `path:"bucket"`
}) (*emptyBody, error) {
a, err := authFromCtx(ctx)
if err != nil {
return nil, err
}
if err := handler.DoDelete(ctx, &models.Bucket{ID: in.BucketID, ProjectID: in.ProjectID, ProjectViewID: in.ViewID}, a); err != nil {
return nil, translateDomainError(err)
}
return &emptyBody{}, nil
}

View File

@ -37,7 +37,7 @@ func RegisterProjectDuplicateRoutes(api huma.API) {
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.",
Description: "Deep-copies a project — its tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds — into a new project owned by the authenticated user. User/team/link shares are only copied when duplicate_shares is set to true. 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,

View File

@ -3438,7 +3438,7 @@ const docTemplate = `{
"JWTKeyAuth": []
}
],
"description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.",
"description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.",
"consumes": [
"application/json"
],
@ -7456,7 +7456,7 @@ const docTemplate = `{
},
"/user/logout": {
"post": {
"description": "Destroys the current session and clears the refresh token cookie.",
"description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an ` + "`" + `oidc_logout_url` + "`" + ` the client should redirect to so the provider session is ended too.",
"produces": [
"application/json"
],
@ -7468,7 +7468,7 @@ const docTemplate = `{
"200": {
"description": "Successfully logged out.",
"schema": {
"$ref": "#/definitions/models.Message"
"$ref": "#/definitions/v1.LogoutResponse"
}
}
}
@ -9665,6 +9665,10 @@ const docTemplate = `{
"models.ProjectDuplicate": {
"type": "object",
"properties": {
"duplicate_shares": {
"description": "Whether to copy the project's shares to the duplicate",
"type": "boolean"
},
"duplicated_project": {
"description": "The copied project",
"allOf": [
@ -10902,6 +10906,10 @@ const docTemplate = `{
"caldav_enabled": {
"type": "boolean"
},
"concurrent_writes": {
"description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.",
"type": "boolean"
},
"demo_mode_enabled": {
"type": "boolean"
},
@ -11141,6 +11149,18 @@ const docTemplate = `{
}
}
},
"v1.LogoutResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"oidc_logout_url": {
"description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.",
"type": "string"
}
}
},
"v1.UserAvatarProvider": {
"type": "object",
"properties": {

View File

@ -3430,7 +3430,7 @@
"JWTKeyAuth": []
}
],
"description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations, backgrounds, user/team permissions and link shares from one project to a new one. The user needs read access in the project and write access in the parent of the new project.",
"description": "Copies the project, tasks, files, kanban data, assignees, comments, attachments, labels, relations and backgrounds from one project to a new one. User/team permissions and link shares are only copied when duplicate_shares is set to true. The user needs read access in the project and write access in the parent of the new project.",
"consumes": [
"application/json"
],
@ -7448,7 +7448,7 @@
},
"/user/logout": {
"post": {
"description": "Destroys the current session and clears the refresh token cookie.",
"description": "Destroys the current session and clears the refresh token cookie. For OpenID Connect sessions the response includes an `oidc_logout_url` the client should redirect to so the provider session is ended too.",
"produces": [
"application/json"
],
@ -7460,7 +7460,7 @@
"200": {
"description": "Successfully logged out.",
"schema": {
"$ref": "#/definitions/models.Message"
"$ref": "#/definitions/v1.LogoutResponse"
}
}
}
@ -9657,6 +9657,10 @@
"models.ProjectDuplicate": {
"type": "object",
"properties": {
"duplicate_shares": {
"description": "Whether to copy the project's shares to the duplicate",
"type": "boolean"
},
"duplicated_project": {
"description": "The copied project",
"allOf": [
@ -10894,6 +10898,10 @@
"caldav_enabled": {
"type": "boolean"
},
"concurrent_writes": {
"description": "ConcurrentWrites reports whether the configured database can handle concurrent writes. It is false on SQLite, where overlapping write transactions deadlock, so clients should serialize batched writes instead of firing them in parallel.",
"type": "boolean"
},
"demo_mode_enabled": {
"type": "boolean"
},
@ -11133,6 +11141,18 @@
}
}
},
"v1.LogoutResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"oidc_logout_url": {
"description": "RP-Initiated Logout URL the frontend redirects to. Empty for non-OIDC sessions.",
"type": "string"
}
}
},
"v1.UserAvatarProvider": {
"type": "object",
"properties": {

View File

@ -577,6 +577,9 @@ definitions:
type: object
models.ProjectDuplicate:
properties:
duplicate_shares:
description: Whether to copy the project's shares to the duplicate
type: boolean
duplicated_project:
allOf:
- $ref: '#/definitions/models.Project'
@ -1534,6 +1537,12 @@ definitions:
type: array
caldav_enabled:
type: boolean
concurrent_writes:
description: ConcurrentWrites reports whether the configured database can
handle concurrent writes. It is false on SQLite, where overlapping write
transactions deadlock, so clients should serialize batched writes instead
of firing them in parallel.
type: boolean
demo_mode_enabled:
type: boolean
email_reminders_enabled:
@ -1705,6 +1714,15 @@ definitions:
password:
type: string
type: object
v1.LogoutResponse:
properties:
message:
type: string
oidc_logout_url:
description: RP-Initiated Logout URL the frontend redirects to. Empty for
non-OIDC sessions.
type: string
type: object
v1.UserAvatarProvider:
properties:
avatar_provider:
@ -4703,9 +4721,10 @@ paths:
consumes:
- application/json
description: Copies the project, tasks, files, kanban data, assignees, comments,
attachments, labels, relations, backgrounds, user/team permissions and link
shares from one project to a new one. The user needs read access in the project
and write access in the parent of the new project.
attachments, labels, relations and backgrounds from one project to a new one.
User/team permissions and link shares are only copied when duplicate_shares
is set to true. The user needs read access in the project and write access
in the parent of the new project.
parameters:
- description: The project ID to duplicate
in: path
@ -6935,13 +6954,15 @@ paths:
/user/logout:
post:
description: Destroys the current session and clears the refresh token cookie.
For OpenID Connect sessions the response includes an `oidc_logout_url` the
client should redirect to so the provider session is ended too.
produces:
- application/json
responses:
"200":
description: Successfully logged out.
schema:
$ref: '#/definitions/models.Message'
$ref: '#/definitions/v1.LogoutResponse'
summary: Logout
tags:
- auth

View File

@ -130,7 +130,7 @@ func TestHumaLogout(t *testing.T) {
// Create a session so logout has something to delete, then mint a JWT whose
// sid claim points at it.
s := db.NewSession()
session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false)
session, err := models.CreateSession(s, testuser1.ID, "test", "127.0.0.1", false, nil)
require.NoError(t, err)
require.NoError(t, s.Commit())
require.NoError(t, s.Close())

View File

@ -0,0 +1,265 @@
// 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 <https://www.gnu.org/licenses/>.
package webtests
import (
"encoding/json"
"net/http"
"testing"
"code.vikunja.io/api/pkg/db"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBucket covers the nested kanban-bucket CRUD on /api/v2. Buckets live under
// /projects/{project}/views/{view}/buckets, so the harness binds the project and
// view in basePath and idParam picks {bucket}.
//
// Permission model — Bucket.Can{Create,Update,Delete} all delegate to
// Project.CanUpdate, which resolves to write access (not admin). Bucket.ReadAll
// only needs the view's read access. So write is the boundary for mutation,
// unlike project views where admin is required.
//
// Fixture topology (see pkg/db/fixtures):
// - project 1 (owned by testuser1), kanban view 4: buckets 1, 2, 3.
// - project 2 (owned by user3, no share to testuser1), kanban view 8:
// buckets 4, 40 — the forbidden / non-member negatives.
// - projects 9/10/11 are owned by user6 and shared to testuser1 read/write/admin;
// their kanban views 36/40/44 carry buckets {9,25}/{10,26}/{11,27}. The same
// user exercises every rung by switching the parent path.
func TestHumaBucket(t *testing.T) {
// project 1 is owned by testuser1.
owned := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/1/views/4/buckets",
idParam: "bucket",
t: t,
}
require.NoError(t, owned.ensureEnv())
// project 2 is owned by user3; testuser1 has no access. Share owned's Echo
// instance: each setupTestEnv() regenerates the global JWT signing secret,
// so two independent harnesses would invalidate each other's tokens.
forbidden := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/2/views/8/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 9 is shared to testuser1 read-only — enough to list, below the
// write bar mutation requires.
readShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/9/views/36/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 10 is shared with write — the rung that clears Project.CanUpdate,
// so it can create/update/delete buckets.
writeShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/10/views/40/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
// project 11 is shared with admin — write access is a subset, so it can do
// everything too.
adminShared := webHandlerTestV2{
user: &testuser1,
basePath: "/api/v2/projects/11/views/44/buckets",
idParam: "bucket",
t: t,
e: owned.e,
}
t.Run("ReadAll", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testReadAllWithUser(nil, nil)
require.NoError(t, err)
// view 4 has exactly buckets 1, 2, 3 in position order.
ids, viewIDs := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{1, 2, 3}, ids)
for _, vid := range viewIDs {
assert.Equal(t, int64(4), vid, "every returned bucket must belong to view 4")
}
assert.Contains(t, rec.Body.String(), `"total":3`)
})
t.Run("Read-only share can list", func(t *testing.T) {
// ReadAll only needs the view's read access; a read share suffices.
rec, err := readShared.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{9, 25}, ids)
})
t.Run("Write share can list", func(t *testing.T) {
rec, err := writeShared.testReadAllWithUser(nil, nil)
require.NoError(t, err)
ids, _ := bucketsFromReadAll(t, rec.Body.Bytes())
assert.ElementsMatch(t, []int64{10, 26}, ids)
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testReadAllWithUser(nil, nil)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Create", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testCreateWithUser(nil, nil, `{"title":"New bucket","limit":5}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"New bucket"`)
assert.Contains(t, rec.Body.String(), `"limit":5`)
// ownership: the view from the URL wins over the body.
assert.Contains(t, rec.Body.String(), `"project_view_id":4`)
})
t.Run("Write share can create", func(t *testing.T) {
// write access clears Project.CanUpdate → Bucket.CanCreate passes.
rec, err := writeShared.testCreateWithUser(nil, nil, `{"title":"Write made"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"Write made"`)
assert.Contains(t, rec.Body.String(), `"project_view_id":40`)
})
t.Run("Admin share can create", func(t *testing.T) {
rec, err := adminShared.testCreateWithUser(nil, nil, `{"title":"Admin made"}`)
require.NoError(t, err)
assert.Equal(t, http.StatusCreated, rec.Code)
assert.Contains(t, rec.Body.String(), `"title":"Admin made"`)
assert.Contains(t, rec.Body.String(), `"project_view_id":44`)
})
t.Run("Read share cannot create", func(t *testing.T) {
// read share is below the write bar Bucket.CanCreate enforces.
_, err := readShared.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testCreateWithUser(nil, nil, `{"title":"Nope"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Empty title", func(t *testing.T) {
// Title has valid:"required" / minLength:"1" → 422 before the model.
_, err := owned.testCreateWithUser(nil, nil, `{"title":""}`)
require.Error(t, err)
assert.Equal(t, http.StatusUnprocessableEntity, getHTTPErrorCode(err))
})
})
t.Run("Update", func(t *testing.T) {
t.Run("Normal", func(t *testing.T) {
rec, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "1"}, `{"title":"Renamed bucket"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Renamed bucket"`)
assert.Contains(t, rec.Body.String(), `"id":1`)
// Only the sent fields are written: the server-managed creator and the
// view scoping from the URL are preserved, not clobbered to zero.
db.AssertExists(t, "buckets", map[string]interface{}{
"id": 1,
"title": "Renamed bucket",
"project_view_id": 4,
"created_by_id": 1,
}, false)
})
t.Run("Write share can update", func(t *testing.T) {
// bucket 10 belongs to view 40 (project 10, write share).
rec, err := writeShared.testUpdateWithUser(nil, map[string]string{"bucket": "10"}, `{"title":"Write renamed"}`)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"title":"Write renamed"`)
assert.Contains(t, rec.Body.String(), `"id":10`)
})
t.Run("Read share cannot update", func(t *testing.T) {
// bucket 9 belongs to view 36 (project 9, read share) → needs write.
_, err := readShared.testUpdateWithUser(nil, map[string]string{"bucket": "9"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := owned.testUpdateWithUser(nil, map[string]string{"bucket": "9999"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
t.Run("Forbidden", func(t *testing.T) {
// bucket 4 belongs to view 8 (project 2) — testuser1 has no access.
_, err := forbidden.testUpdateWithUser(nil, map[string]string{"bucket": "4"}, `{"title":"x"}`)
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
})
t.Run("Delete", func(t *testing.T) {
t.Run("Read share cannot delete", func(t *testing.T) {
// bucket 25 belongs to view 36 (project 9, read share) → needs write.
_, err := readShared.testDeleteWithUser(nil, map[string]string{"bucket": "25"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Write share can delete", func(t *testing.T) {
// bucket 26 belongs to view 40 (project 10, write share); view 40 still
// has bucket 10 (plus the one created above), so it isn't the last.
rec, err := writeShared.testDeleteWithUser(nil, map[string]string{"bucket": "26"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
})
t.Run("Forbidden", func(t *testing.T) {
_, err := forbidden.testDeleteWithUser(nil, map[string]string{"bucket": "40"})
require.Error(t, err)
assert.Equal(t, http.StatusForbidden, getHTTPErrorCode(err))
})
t.Run("Normal", func(t *testing.T) {
// view 4 has buckets 1, 2, 3 (plus the one created above), so deleting
// bucket 2 leaves more than one behind.
rec, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "2"})
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Empty(t, rec.Body.String())
db.AssertMissing(t, "buckets", map[string]interface{}{"id": 2})
})
t.Run("Nonexisting", func(t *testing.T) {
_, err := owned.testDeleteWithUser(nil, map[string]string{"bucket": "9999"})
require.Error(t, err)
assert.Equal(t, http.StatusNotFound, getHTTPErrorCode(err))
})
})
}
// bucketsFromReadAll extracts the bucket ids and their project_view_ids from a v2
// paginated list body so the visible set can be asserted exactly.
func bucketsFromReadAll(t *testing.T, body []byte) (ids []int64, viewIDs []int64) {
t.Helper()
var resp struct {
Items []struct {
ID int64 `json:"id"`
ProjectViewID int64 `json:"project_view_id"`
} `json:"items"`
}
require.NoError(t, json.Unmarshal(body, &resp), "ReadAll body must be a paginated envelope: %s", string(body))
ids = make([]int64, 0, len(resp.Items))
viewIDs = make([]int64, 0, len(resp.Items))
for _, it := range resp.Items {
ids = append(ids, it.ID)
viewIDs = append(viewIDs, it.ProjectViewID)
}
return ids, viewIDs
}

View File

@ -21,6 +21,8 @@ import (
"net/http"
"testing"
"code.vikunja.io/api/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -39,4 +41,7 @@ func TestHumaInfo(t *testing.T) {
assert.Contains(t, body, "version")
assert.Contains(t, body, "auth")
assert.Contains(t, body, "available_migrators")
require.Contains(t, body, "concurrent_writes")
assert.Equal(t, config.DatabaseType.GetString() != "sqlite", body["concurrent_writes"])
}

View File

@ -16,6 +16,7 @@ func init() {
"BucketConfigurationModeNone": reflect.ValueOf(models.BucketConfigurationModeNone),
"CanDoAPIRoute": reflect.ValueOf(models.CanDoAPIRoute),
"CollectRoutesForAPITokenUsage": reflect.ValueOf(models.CollectRoutesForAPITokenUsage),
"CreateDefaultSavedFiltersForUser": reflect.ValueOf(models.CreateDefaultSavedFiltersForUser),
"CreateDefaultViewsForProject": reflect.ValueOf(models.CreateDefaultViewsForProject),
"CreateNewProjectForUser": reflect.ValueOf(models.CreateNewProjectForUser),
"CreateProject": reflect.ValueOf(models.CreateProject),

View File

@ -46,9 +46,36 @@ this file is veans-specific.
## Vikunja wire-format gotchas
Most failures surface when crossing the JSON boundary. The list below is
what's bitten me; if a new endpoint behaves oddly, suspect one of these:
veans targets the Huma-backed **`/api/v2`** exclusively (`apiBasePath` in
`internal/client/client.go`). v1 is frozen, and the kanban-bucket CRUD veans
relies on only exists on v2. Most failures surface when crossing the JSON
boundary. The list below is what's bitten me; if a new endpoint behaves
oddly, suspect one of these:
- **Lists come wrapped in the standard envelope.** Every v2 list returns
`{"items":[...],"total":N,"page":N,"per_page":N,"total_pages":N}`, not a
bare array, and there is no `x-pagination-total-pages` header anymore.
Decode with the generic `Paginated[T]` helper. **Most lists are
server-paginated** — their model's `ReadAll` applies a 50-item page limit:
tasks, projects, labels, comments and bots. Page through those with
`doListAll` until `page >= total_pages`; returning only page 1 silently
truncates (>50 comments on a task is realistic). **Buckets and project
views are the exception**: their `ReadAll` takes `_ int, _ int` and returns
every row in one page, so fetch them with a single `doList` and unwrap
`.items` — paging those would re-fetch the full set and duplicate it.
Single-object responses (create/update/read of one entity) stay UNWRAPPED.
- **v2 flips the create/update verbs.** Creates are **POST** (v1 used PUT):
projects, labels, tokens, bot users, project shares, task create,
comments, relations, assignees, label-attach, bucket create. Task update
is **PATCH** (see below). The bucket-task move is **PUT**.
- **Task update is `PATCH /tasks/{id}` with `application/merge-patch+json`**
(`client.DoMerge` → `UpdateTask(*TaskPatch)`). Only the fields present in
the body are written; absent fields are left intact. Build the body from
`TaskPatch` (pointer fields, omitempty) — never a whole `client.Task`,
whose no-omitempty `done`/`title` would clobber those columns on every
call (this was issue #2962).
- **List search is `q`**, not v1's `s` (`ListParams.Q`). Task-list
`filter`/`expand`/`page`/`per_page` keep their names.
- **`ProjectView.view_kind` and `bucket_configuration_mode` are
strings**, not ints. The parent enums (`ProjectViewKind`,
`BucketConfigurationModeKind`) have custom `MarshalJSON` that emits
@ -58,11 +85,12 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
`xorm:"-"` on it — the actual bucket lives in a separate
`task_buckets` table. Fetch with `?expand=buckets` and use
`task.CurrentBucketID(viewID)` to read it.
- **`POST /tasks/{id}` does NOT move tasks between buckets.** The
task↔bucket relation is row-shaped; use `client.MoveTaskToBucket()`
which hits `POST /projects/{p}/views/{v}/buckets/{b}/tasks`. The
Update path on the server only auto-moves on `done` flips.
- **Bot user creation is `PUT /user/bots`**, not `/bots` — the routes
- **Task updates do NOT move tasks between buckets.** The task↔bucket
relation is row-shaped; use `client.MoveTaskToBucket()` which hits
**`PUT /projects/{p}/views/{v}/buckets/{b}/tasks`** with a `{"task_id":N}`
body (project/view/bucket all come from the URL). The Update path on the
server only auto-moves on `done` flips.
- **Bot user creation is `POST /user/bots`**, not `/bots` — the routes
are registered under the `/user` subgroup. Same prefix for
`GET /user/bots`.
- **`APIToken.expires_at` is required.** The struct field has
@ -88,6 +116,16 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
- `/projects/:project/views/:view/buckets/:bucket/tasks`
group `projects`, action `views_buckets_tasks`
- `/tasks/:task/comments` → group `tasks_comments`, action `create`
- v1 and v2 deliberately share `(group, permission)` keys:
`pkg/models/api_routes.go` normalizes the inverted verbs (v2 POST-create
and v1 PUT-create both → `create`; v2 PUT/PATCH-update and v1 POST-update
both → `update`), and `CanDoAPIRoute` consults both route tables, treating
PATCH as an alias for the stored PUT. So `PermissionsForBot`'s scope map
authorizes the v2 calls unchanged, including the PATCH task update.
- The bucket-task MOVE (`PUT …/buckets/:bucket/tasks`) and the
buckets-with-tasks LIST (`GET …/buckets/tasks`) collide on subkey
`views_buckets_tasks`; which one gets the bare key vs `views_buckets_tasks_put`
depends on unspecified route-init order, so the bot requests **both**.
- `client.PermissionsForBot()` calls `GET /routes` at runtime and
grants only the intersection of what we want and what the server
exposes. **Don't hard-code permission group names** — they drift
@ -96,9 +134,9 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
## Bot ownership and token minting
- Creating a bot via `PUT /user/bots` automatically sets the bot's
- Creating a bot via `POST /user/bots` automatically sets the bot's
`bot_owner_id` to the calling user. Only the owner can mint tokens
for the bot via `PUT /tokens` with `owner_id=<bot_id>`. The init
for the bot via `POST /tokens` with `owner_id=<bot_id>`. The init
flow does these as a single human-JWT-authenticated batch.
- Bots have no password and **cannot** authenticate via `POST /login`.
After init, `veans login` re-authenticates as the human (not the
@ -115,9 +153,11 @@ what's bitten me; if a new endpoint behaves oddly, suspect one of these:
browser, and captures the callback. The `Shutdown` defer uses
`context.WithoutCancel(ctx)` so cancellation at the outer scope
still drains the loopback server cleanly.
- Token exchange is **JSON only**. Form-encoded POSTs to `/oauth/token`
fail; the standard `golang.org/x/oauth2` client speaks form encoding,
which is why we have a hand-rolled `client.ExchangeOAuthCode`.
- Token exchange goes out as **JSON**. v2's `/oauth/token` accepts both JSON
and form-encoded bodies (Huma picks the decoder off the `Content-Type`
header), but the standard `golang.org/x/oauth2` client hard-codes form
encoding and its own response shape, so we keep the hand-rolled
`client.ExchangeOAuthCode` that speaks JSON.
## Credential store

View File

@ -102,11 +102,14 @@ func TestInit_HappyPath(t *testing.T) {
t.Fatalf("bot %q not found on server", ws.BotUsername)
}
// Project shared with the bot at write permission.
var shares []map[string]any
// Project shared with the bot at write permission. v2 lists come wrapped
// in the standard {items,...} envelope.
var shares struct {
Items []map[string]any `json:"items"`
}
_ = h.AdminClient.Do(t.Context(), "GET", fmt.Sprintf("/projects/%d/users", project.ID), nil, nil, &shares)
shareFound := false
for _, s := range shares {
for _, s := range shares.Items {
if u, _ := s["username"].(string); u == ws.BotUsername {
if p, _ := s["permission"].(float64); int(p) >= 1 {
shareFound = true

View File

@ -257,7 +257,7 @@ func Init(ctx context.Context, opts *Options) (*Result, error) {
return nil, output.Wrap(output.CodeUnknown, err, "mint bot token: %v", err)
}
if mintedToken.Token == "" {
return nil, output.New(output.CodeUnknown, "PUT /tokens did not return a token plaintext — cannot continue")
return nil, output.New(output.CodeUnknown, "POST /tokens did not return a token plaintext — cannot continue")
}
// 11. Persist credentials. Discard human JWT immediately after.

View File

@ -211,8 +211,9 @@ func TestConfirmOverwriteExistingConfig(t *testing.T) {
}
// bucketServer is a minimal httptest server modelling
// GET/PUT /api/v1/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; PUT requests append to that list with a synthetic ID.
// GET/POST /api/v2/projects/{p}/views/{v}/buckets. The caller pre-seeds
// existing buckets; POST requests append to that list with a synthetic ID.
// GET returns the standard v2 list envelope; POST returns the bare bucket.
type bucketServer struct {
mu sync.Mutex
existing []*client.Bucket
@ -232,7 +233,7 @@ func newBucketServer(seed []*client.Bucket) *bucketServer {
func (s *bucketServer) handler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Path is /api/v1/projects/{p}/views/{v}/buckets.
// Path is /api/v2/projects/{p}/views/{v}/buckets.
if !strings.HasSuffix(r.URL.Path, "/buckets") || !strings.Contains(r.URL.Path, "/views/") {
http.Error(w, "unexpected path: "+r.URL.Path, http.StatusInternalServerError)
return
@ -242,8 +243,15 @@ func (s *bucketServer) handler() http.Handler {
switch r.Method {
case http.MethodGet:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.existing)
case http.MethodPut:
// v2 list envelope; the buckets list isn't server-paginated.
_ = json.NewEncoder(w).Encode(map[string]any{
"items": s.existing,
"total": len(s.existing),
"page": 1,
"per_page": 50,
"total_pages": 1,
})
case http.MethodPost:
var b client.Bucket
if err := json.NewDecoder(r.Body).Decode(&b); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)

View File

@ -23,5 +23,5 @@ import (
// AddAssignee assigns a user (typically the bot) to a task.
func (c *Client) AddAssignee(ctx context.Context, taskID, userID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/assignees", taskID), nil, &TaskAssignee{UserID: userID}, nil)
}

View File

@ -21,25 +21,28 @@ import (
"fmt"
)
// ListBuckets returns the buckets configured on a Kanban view.
// ListBuckets returns the buckets configured on a Kanban view. Bucket.ReadAll
// ignores page/per_page and returns every bucket in a single page (the envelope
// total reflects the full set), so one GET gets them all — paging would
// re-fetch the same buckets and duplicate them. Unwrap .items.
func (c *Client) ListBuckets(ctx context.Context, projectID, viewID int64) ([]*Bucket, error) {
var out []*Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if err := c.Do(ctx, "GET", path, nil, nil, &out); err != nil {
items, _, err := doList[*Bucket](ctx, c, path, nil)
if err != nil {
return nil, err
}
return out, nil
return items, nil
}
// CreateBucket inserts a new bucket into a Kanban view.
// CreateBucket inserts a new bucket into a Kanban view. The project and view
// come from the URL; the v2 handler ignores project_view_id in the body.
func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *Bucket) (*Bucket, error) {
var out Bucket
path := fmt.Sprintf("/projects/%d/views/%d/buckets", projectID, viewID)
if b == nil {
b = &Bucket{}
}
b.ProjectViewID = viewID
if err := c.Do(ctx, "PUT", path, nil, b, &out); err != nil {
if err := c.Do(ctx, "POST", path, nil, b, &out); err != nil {
return nil, err
}
return &out, nil
@ -47,17 +50,13 @@ func (c *Client) CreateBucket(ctx context.Context, projectID, viewID int64, b *B
// MoveTaskToBucket positions an existing task in `bucketID` on the
// project's view. Vikunja stores task↔bucket relations in a separate
// table (`task_buckets`), so POST /tasks/{id} with bucket_id does not
// reliably move tasks — this dedicated endpoint is the one the Kanban
// UI's drag-and-drop uses.
// table (`task_buckets`); a task update with bucket_id does not reliably
// move tasks — this dedicated endpoint is the one the Kanban UI's
// drag-and-drop uses. On v2 it's a PUT, and project/view/bucket all come
// from the URL, so the body only carries the task id.
func (c *Client) MoveTaskToBucket(ctx context.Context, projectID, viewID, bucketID, taskID int64) error {
path := fmt.Sprintf("/projects/%d/views/%d/buckets/%d/tasks",
projectID, viewID, bucketID)
body := map[string]int64{
"task_id": taskID,
"project_view_id": viewID,
"bucket_id": bucketID,
"project_id": projectID,
}
return c.Do(ctx, "POST", path, nil, body, nil)
body := map[string]int64{"task_id": taskID}
return c.Do(ctx, "PUT", path, nil, body, nil)
}

View File

@ -33,7 +33,7 @@ import (
// Client is a thin JSON wrapper around the Vikunja REST API. It holds the
// server base URL and a bearer token (either a JWT from POST /login or an
// API token minted via PUT /tokens). Every method in this package is a thin
// API token minted via POST /tokens). Every method in this package is a thin
// shim over Do.
type Client struct {
BaseURL string
@ -41,6 +41,19 @@ type Client struct {
HTTPClient *http.Client
}
// apiBasePath is the version prefix every request is mounted under. veans
// targets the Huma-backed /api/v2 exclusively — v1 is frozen and the bucket
// CRUD endpoints veans needs only exist on v2.
const apiBasePath = "/api/v2"
// contentTypeJSON / contentTypeMergePatch are the request body content types
// Do and DoMerge send. Merge-patch (RFC 7396) is how v2 does partial updates:
// only the fields present in the body are written, the rest are left intact.
const (
contentTypeJSON = "application/json"
contentTypeMergePatch = "application/merge-patch+json"
)
// UserAgent is the value sent in the User-Agent header on every request.
// main sets this at startup with the linker-injected version + the
// runtime os/arch (e.g. "veans/0.3.1 (linux/amd64)"). Tests get the
@ -61,17 +74,36 @@ func New(baseURL, token string) *Client {
}
}
// vikunjaError matches `web.HTTPError` on the wire.
// vikunjaError matches the RFC 9457 problem+json body /api/v2 returns
// (huma.ErrorModel augmented with Vikunja's numeric domain `code`). The
// human-readable message lives in `detail`; `title` is the status text
// fallback. `message` is v1's legacy field, kept only as a fallback so a
// stray legacy/proxy error body still yields a readable message instead of
// raw JSON. The HTTP status used for output.Code mapping comes from the
// response status line, not this body.
type vikunjaError struct {
Code int `json:"code"`
Title string `json:"title"`
Detail string `json:"detail"`
Message string `json:"message"`
Code int `json:"code"`
}
// Do performs a single JSON request against /api/v1<path>. body, if non-nil,
// Do performs a single JSON request against /api/v2<path>. body, if non-nil,
// is JSON-marshalled. out, if non-nil, is JSON-unmarshalled. query is appended
// as URL-encoded params.
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
full := c.BaseURL + "/api/v1" + path
return c.do(ctx, method, path, query, body, out, contentTypeJSON)
}
// DoMerge is like Do but sends the body as a JSON Merge Patch
// (application/merge-patch+json). Used for PATCH updates so only the fields
// present in `body` are written server-side — see UpdateTask.
func (c *Client) DoMerge(ctx context.Context, method, path string, query url.Values, body, out any) error {
return c.do(ctx, method, path, query, body, out, contentTypeMergePatch)
}
func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any, contentType string) error {
full := c.BaseURL + apiBasePath + path
if len(query) > 0 {
full += "?" + query.Encode()
}
@ -91,7 +123,7 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
}
req.Header.Set("Accept", "application/json")
if body != nil {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", contentType)
}
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
@ -122,51 +154,55 @@ func (c *Client) Do(ctx context.Context, method, path string, query url.Values,
return nil
}
// DoPaginated is like Do but also returns the total page count parsed from
// the `x-pagination-total-pages` response header (0 if the header is
// missing or unparseable). Used by the list endpoints so paging terminates
// against the authoritative server count, not a `len(batch) < per_page`
// heuristic that loops one extra time on exact-multiple totals.
func (c *Client) DoPaginated(ctx context.Context, method, path string, query url.Values, out any) (totalPages int, err error) {
full := c.BaseURL + "/api/v1" + path
if len(query) > 0 {
full += "?" + query.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, full, nil)
if err != nil {
return 0, fmt.Errorf("build request: %w", err)
}
req.Header.Set("Accept", "application/json")
if c.Token != "" {
req.Header.Set("Authorization", "Bearer "+c.Token)
}
req.Header.Set("User-Agent", UserAgent)
// Paginated mirrors the standard /api/v2 list envelope. Every v2 list
// operation returns this shape (v1 returned a bare array plus an
// x-pagination-total-pages header, which is gone). Single-object responses
// stay unwrapped.
type Paginated[T any] struct {
Items []T `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
resp, err := c.HTTPClient.Do(req)
if err != nil {
return 0, output.Wrap(output.CodeUnknown, err, "%s %s: %v", method, path, err)
// doList GETs `path` and decodes the standard v2 list envelope, returning the
// items plus the server's total page count so a caller can page until
// page >= totalPages. Generic so each list endpoint reuses it without a
// per-type wrapper struct.
func doList[T any](ctx context.Context, c *Client, path string, query url.Values) (items []T, totalPages int, err error) {
var env Paginated[T]
if err := c.Do(ctx, "GET", path, query, nil, &env); err != nil {
return nil, 0, err
}
defer resp.Body.Close()
return env.Items, env.TotalPages, nil
}
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
if err != nil {
return 0, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode >= 400 {
return 0, mapHTTPError(method, path, resp.StatusCode, respBody,
parseRetryAfter(resp.Header.Get("Retry-After")))
}
if out != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, out); err != nil {
return 0, fmt.Errorf("decode %s %s: %w", method, path, err)
// doListAll pages through a v2 list endpoint, accumulating every item until
// page >= total_pages.
//
// Use it ONLY for endpoints whose model honours page/per_page — the
// server-paginated lists (tasks, projects, labels, comments, bots). For the
// endpoints whose ReadAll ignores pagination and returns every row in a single
// page (buckets, views), call doList instead: looping those re-fetches the full
// set on every page and duplicates it.
func doListAll[T any](ctx context.Context, c *Client, path string) ([]T, error) {
var all []T
page := 1
for {
q := url.Values{}
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
batch, totalPages, err := doList[T](ctx, c, path, q)
if err != nil {
return nil, err
}
}
if v := resp.Header.Get("x-pagination-total-pages"); v != "" {
if n, perr := strconv.Atoi(v); perr == nil {
totalPages = n
all = append(all, batch...)
if page >= totalPages {
return all, nil
}
page++
}
return totalPages, nil
}
// DoRaw is the escape hatch used by `veans api`. It returns the raw response
@ -176,7 +212,7 @@ func (c *Client) DoPaginated(ctx context.Context, method, path string, query url
// "stdout is for the success payload; errors go through the envelope on
// stderr"); see commands/api.go for the canonical handling.
func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Values, body []byte) (status int, respBody []byte, retryAfter time.Duration, err error) {
full := c.BaseURL + "/api/v1" + path
full := c.BaseURL + apiBasePath + path
if len(query) > 0 {
full += "?" + query.Encode()
}
@ -205,18 +241,6 @@ func (c *Client) DoRaw(ctx context.Context, method, path string, query url.Value
return resp.StatusCode, respBody, parseRetryAfter(resp.Header.Get("Retry-After")), err
}
// paginationDone reports whether a paged GET has consumed every page,
// preferring the server's x-pagination-total-pages count when present and
// falling back to the len(batch) < per_page heuristic when the header is
// missing (older server / proxy stripped). Centralized so all list
// endpoints terminate identically.
func paginationDone(page, batchLen, perPage, totalPages int) bool {
if totalPages > 0 {
return page >= totalPages
}
return batchLen < perPage
}
// maxBodyBytes caps the size of any response body we'll read into memory.
// Vikunja JSON payloads are far smaller; the cap exists so a misbehaving
// proxy can't OOM the CLI by streaming an unbounded body.
@ -244,7 +268,16 @@ func parseRetryAfter(v string) time.Duration {
func mapHTTPError(method, path string, status int, body []byte, retryAfter time.Duration) error {
var ve vikunjaError
_ = json.Unmarshal(body, &ve)
msg := strings.TrimSpace(ve.Message)
// v2's problem+json carries the human-readable text in `detail`; fall back
// to `title`, then v1's legacy `message`, then the raw body, then the
// status text.
msg := strings.TrimSpace(ve.Detail)
if msg == "" {
msg = strings.TrimSpace(ve.Title)
}
if msg == "" {
msg = strings.TrimSpace(ve.Message)
}
if msg == "" {
msg = strings.TrimSpace(string(body))
if msg == "" {

View File

@ -45,7 +45,7 @@ func TestMapHTTPError_StatusCodeMapping(t *testing.T) {
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := mapHTTPError("GET", "/foo", tc.status, []byte(`{"message":"boom"}`), 0)
err := mapHTTPError("GET", "/foo", tc.status, []byte(`{"detail":"boom"}`), 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
@ -59,7 +59,7 @@ func TestMapHTTPError_StatusCodeMapping(t *testing.T) {
func TestMapHTTPError_RetryAfterAppendedToMessage(t *testing.T) {
retry := 7 * time.Second
err := mapHTTPError("GET", "/foo", http.StatusTooManyRequests, []byte(`{"message":"slow down"}`), retry)
err := mapHTTPError("GET", "/foo", http.StatusTooManyRequests, []byte(`{"detail":"slow down"}`), retry)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
@ -89,20 +89,53 @@ func TestMapHTTPError_BodyTruncation(t *testing.T) {
}
}
func TestMapHTTPError_VikunjaJSONTakesPrecedenceOverRawBody(t *testing.T) {
body := []byte(`{"code":404,"message":"x"}`)
func TestMapHTTPError_VikunjaProblemJSONTakesPrecedenceOverRawBody(t *testing.T) {
// v2 returns RFC 9457 problem+json: the message is in `detail`, and `code`
// carries Vikunja's numeric domain error code (not the HTTP status).
body := []byte(`{"status":404,"title":"Not Found","detail":"x","code":3001}`)
err := mapHTTPError("GET", "/foo", http.StatusNotFound, body, 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
}
// The formatted message is "METHOD PATH: STATUS MSG"; assert it carries
// the decoded message and not the raw JSON envelope.
// the decoded `detail` and not the raw JSON envelope.
if !strings.HasSuffix(oe.Message, ": 404 x") {
t.Errorf("expected formatted message to end with %q, got %q", ": 404 x", oe.Message)
}
if strings.Contains(oe.Message, `"code":404`) {
t.Errorf("expected raw JSON body to be replaced by decoded message, got %q", oe.Message)
if strings.Contains(oe.Message, `"code"`) {
t.Errorf("expected raw JSON body to be replaced by decoded detail, got %q", oe.Message)
}
}
func TestMapHTTPError_FallsBackToTitleWhenNoDetail(t *testing.T) {
// A problem+json body with no `detail` (e.g. Huma's own schema-validation
// 422 sometimes only sets title) falls back to `title`.
body := []byte(`{"status":422,"title":"Unprocessable Entity"}`)
err := mapHTTPError("PATCH", "/tasks/1", http.StatusUnprocessableEntity, body, 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
}
if !strings.HasSuffix(oe.Message, ": 422 Unprocessable Entity") {
t.Errorf("expected title fallback, got %q", oe.Message)
}
}
func TestMapHTTPError_FallsBackToLegacyMessage(t *testing.T) {
// Defensive: a stray legacy/proxy body with only v1's `message` field
// still yields the message text rather than the raw JSON.
body := []byte(`{"code":403,"message":"forbidden"}`)
err := mapHTTPError("GET", "/foo", http.StatusForbidden, body, 0)
var oe *output.Error
if !errors.As(err, &oe) {
t.Fatalf("expected *output.Error, got %T", err)
}
if !strings.HasSuffix(oe.Message, ": 403 forbidden") {
t.Errorf("expected legacy message fallback, got %q", oe.Message)
}
if strings.Contains(oe.Message, `"message"`) {
t.Errorf("expected raw JSON to be replaced by the message text, got %q", oe.Message)
}
}
@ -146,36 +179,9 @@ func TestParseRetryAfter(t *testing.T) {
}
}
func TestPaginationDone(t *testing.T) {
cases := []struct {
name string
page int
batchLen int
perPage int
totalPages int
want bool
}{
{"server says single page complete", 1, 50, 50, 1, true},
{"server says more pages remain", 1, 50, 50, 2, false},
{"server says we're on the last page", 2, 10, 50, 2, true},
{"no header, full page -> not done", 1, 50, 50, 0, false},
{"no header, short page -> done", 1, 10, 50, 0, true},
{"no header, empty page -> done", 1, 0, 50, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := paginationDone(tc.page, tc.batchLen, tc.perPage, tc.totalPages)
if got != tc.want {
t.Errorf("paginationDone(page=%d, batch=%d, per=%d, total=%d) = %v, want %v",
tc.page, tc.batchLen, tc.perPage, tc.totalPages, got, tc.want)
}
})
}
}
func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/bots" {
if r.Method != http.MethodPost || r.URL.Path != "/api/v2/user/bots" {
http.Error(w, "unexpected route", http.StatusInternalServerError)
return
}
@ -196,3 +202,129 @@ func TestCreateBotUser_404TranslatesToBotUsersUnavailable(t *testing.T) {
t.Errorf("got code %q, want %q", oe.Code, output.CodeBotUsersUnavailable)
}
}
// TestListProjects_PaginatesEnvelope verifies the v2 list shape: each page is
// the {items,total,page,per_page,total_pages} envelope, and ListProjects keeps
// requesting until page >= total_pages, accumulating every item.
func TestListProjects_PaginatesEnvelope(t *testing.T) {
var gotPages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/projects" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
page := r.URL.Query().Get("page")
gotPages = append(gotPages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "1":
_, _ = w.Write([]byte(`{"items":[{"id":1,"title":"a"},{"id":2,"title":"b"}],"total":3,"page":1,"per_page":2,"total_pages":2}`))
case "2":
_, _ = w.Write([]byte(`{"items":[{"id":3,"title":"c"}],"total":3,"page":2,"per_page":2,"total_pages":2}`))
default:
t.Errorf("unexpected page %q (would loop past the end)", page)
http.Error(w, "no such page", http.StatusBadRequest)
}
}))
defer srv.Close()
projects, err := New(srv.URL, "tk").ListProjects(context.Background())
if err != nil {
t.Fatalf("ListProjects: %v", err)
}
if len(projects) != 3 {
t.Fatalf("expected 3 projects accumulated across 2 pages, got %d", len(projects))
}
if len(gotPages) != 2 || gotPages[0] != "1" || gotPages[1] != "2" {
t.Fatalf("expected exactly pages [1 2], got %v", gotPages)
}
}
// TestListTaskComments_PaginatesEnvelope guards the truncation bug: the v2
// comments endpoint is server-paginated, so a task with >50 comments spans
// multiple pages and ListTaskComments must accumulate them all, not stop at
// page 1.
func TestListTaskComments_PaginatesEnvelope(t *testing.T) {
var pages []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/tasks/9/comments" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
page := r.URL.Query().Get("page")
pages = append(pages, page)
w.Header().Set("Content-Type", "application/json")
switch page {
case "1":
_, _ = w.Write([]byte(`{"items":[{"id":1,"comment":"a"},{"id":2,"comment":"b"}],"total":3,"page":1,"per_page":2,"total_pages":2}`))
case "2":
_, _ = w.Write([]byte(`{"items":[{"id":3,"comment":"c"}],"total":3,"page":2,"per_page":2,"total_pages":2}`))
default:
t.Errorf("unexpected page %q", page)
http.Error(w, "no such page", http.StatusBadRequest)
}
}))
defer srv.Close()
comments, err := New(srv.URL, "tk").ListTaskComments(context.Background(), 9)
if err != nil {
t.Fatalf("ListTaskComments: %v", err)
}
if len(comments) != 3 {
t.Fatalf("expected 3 comments across 2 pages, got %d (truncation regression?)", len(comments))
}
if len(pages) != 2 {
t.Fatalf("expected to fetch 2 pages, got %v", pages)
}
}
// TestListBuckets_SingleFetchDoesNotPage pins the opposite invariant: the
// buckets model returns every row in one page, so ListBuckets must issue a
// single request even when the envelope's total_pages is >1 — paging would
// re-fetch and duplicate the buckets.
func TestListBuckets_SingleFetchDoesNotPage(t *testing.T) {
var requests int
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests++
if requests > 1 {
t.Errorf("ListBuckets paged a single-page endpoint (request %d) — would duplicate", requests)
}
w.Header().Set("Content-Type", "application/json")
// total_pages deliberately > 1 to prove ListBuckets ignores it.
_, _ = w.Write([]byte(`{"items":[{"id":1,"title":"Todo"},{"id":2,"title":"Doing"}],"total":2,"page":1,"per_page":1,"total_pages":2}`))
}))
defer srv.Close()
buckets, err := New(srv.URL, "tk").ListBuckets(context.Background(), 7, 3)
if err != nil {
t.Fatalf("ListBuckets: %v", err)
}
if requests != 1 {
t.Fatalf("expected exactly 1 request, got %d", requests)
}
if len(buckets) != 2 {
t.Fatalf("expected the 2 buckets from the single page, got %d", len(buckets))
}
}
// TestListProjectViews_UnwrapsEnvelope pins that a previously-single-GET list
// (project views) now unwraps .items from the standard list envelope.
func TestListProjectViews_UnwrapsEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/projects/7/views" {
http.Error(w, "unexpected path "+r.URL.Path, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"items":[{"id":10,"title":"Kanban","view_kind":"kanban"}],"total":1,"page":1,"per_page":50,"total_pages":1}`))
}))
defer srv.Close()
views, err := New(srv.URL, "tk").ListProjectViews(context.Background(), 7)
if err != nil {
t.Fatalf("ListProjectViews: %v", err)
}
if len(views) != 1 || views[0].ViewKind != ViewKindKanban {
t.Fatalf("expected one kanban view unwrapped from .items, got %+v", views)
}
}

View File

@ -24,17 +24,15 @@ import (
// AddTaskComment posts a new comment on a task.
func (c *Client) AddTaskComment(ctx context.Context, taskID int64, body string) (*TaskComment, error) {
var out TaskComment
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil {
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/comments", taskID), nil, &TaskComment{Comment: body}, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListTaskComments returns all comments on a task.
// ListTaskComments returns all comments on a task. The v2 comments endpoint is
// server-paginated (TaskComment.ReadAll applies a 50-item page limit), so page
// through to the end instead of returning only the first page.
func (c *Client) ListTaskComments(ctx context.Context, taskID int64) ([]*TaskComment, error) {
var out []*TaskComment
if err := c.Do(ctx, "GET", fmt.Sprintf("/tasks/%d/comments", taskID), nil, nil, &out); err != nil {
return nil, err
}
return out, nil
return doListAll[*TaskComment](ctx, c, fmt.Sprintf("/tasks/%d/comments", taskID))
}

View File

@ -31,11 +31,15 @@ import (
const defaultAPIPort = "3456"
// DiscoverServer normalizes `input` and probes a small set of plausible
// URLs for /api/v1/info, returning the canonical base URL (without the
// /api/v1 suffix — that's what client.New expects) and the parsed Info.
// URLs for /api/v2/info, returning the canonical base URL (without the
// /api/v2 suffix — that's what client.New expects) and the parsed Info.
//
// Probing /api/v2/info doubles as the "is this server new enough" check: a
// Vikunja without /api/v2 fails discovery cleanly rather than limping along
// against endpoints veans needs.
//
// Mirrors the discovery the Vikunja web frontend does in
// helpers/checkAndSetApiUrl.ts: try the URL as-given, with /api/v1
// helpers/checkAndSetApiUrl.ts: try the URL as-given, with the API path
// appended, and with the default :3456 port — across http / https. The
// first response that parses as Info wins.
func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
@ -53,7 +57,7 @@ func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
var attempts []string
var lastErr error
for _, base := range candidates {
attempts = append(attempts, base+"/api/v1/info")
attempts = append(attempts, base+"/api/v2/info")
info, err := New(base, "").Info(ctx)
if err == nil && info != nil {
return base, info, nil
@ -67,15 +71,16 @@ func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
}
// serverCandidates expands `input` into the ordered list of base URLs
// to probe for /api/v1/info. A "base URL" here is what client.New wants:
// the origin + the path that should sit BEFORE /api/v1 (typically empty
// or a reverse-proxy prefix). The probe itself adds /api/v1/info.
// to probe for /api/v2/info. A "base URL" here is what client.New wants:
// the origin + the path that should sit BEFORE /api/v2 (typically empty
// or a reverse-proxy prefix). The probe itself adds /api/v2/info.
func serverCandidates(input string) ([]string, error) {
// Strip a trailing /api/v1[/] the user might have copied from a
// curl example. We add it back in the probe, and otherwise we'd
// end up calling /api/v1/api/v1/info.
// Strip a trailing /api/v1 or /api/v2[/] the user might have copied
// from a curl example. We add the API path back in the probe, and
// otherwise we'd end up calling /api/v2/api/v2/info.
trimmed := strings.TrimRight(input, "/")
trimmed = strings.TrimSuffix(trimmed, "/api/v1")
trimmed = strings.TrimSuffix(trimmed, "/api/v2")
trimmed = strings.TrimRight(trimmed, "/")
withScheme := trimmed

View File

@ -33,15 +33,15 @@ func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
if search != "" {
q.Set("s", search)
// v2's list search param is `q` (v1 used `s`).
q.Set("q", search)
}
var batch []*Label
total, err := c.DoPaginated(ctx, "GET", "/labels", q, &batch)
batch, totalPages, err := doList[*Label](ctx, c, "/labels", q)
if err != nil {
return nil, err
}
all = append(all, batch...)
if paginationDone(page, len(batch), 50, total) {
if page >= totalPages {
return all, nil
}
page++
@ -51,7 +51,7 @@ func (c *Client) ListLabels(ctx context.Context, search string) ([]*Label, error
// CreateLabel creates a new label owned by the authenticated user.
func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
var out Label
if err := c.Do(ctx, "PUT", "/labels", nil, l, &out); err != nil {
if err := c.Do(ctx, "POST", "/labels", nil, l, &out); err != nil {
return nil, err
}
return &out, nil
@ -59,7 +59,7 @@ func (c *Client) CreateLabel(ctx context.Context, l *Label) (*Label, error) {
// AddLabelToTask attaches an existing label to a task.
func (c *Client) AddLabelToTask(ctx context.Context, taskID, labelID int64) error {
return c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil)
return c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/labels", taskID), nil, &LabelTask{LabelID: labelID}, nil)
}
// RemoveLabelFromTask detaches a label.

View File

@ -23,8 +23,8 @@ import (
"strconv"
)
// ListProjects pages through GET /projects, accumulating until the server's
// x-pagination-total-pages header says we're done.
// ListProjects pages through GET /projects, accumulating until the list
// envelope's total_pages says we're done.
func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
var all []*Project
page := 1
@ -32,13 +32,12 @@ func (c *Client) ListProjects(ctx context.Context) ([]*Project, error) {
q := url.Values{}
q.Set("page", strconv.Itoa(page))
q.Set("per_page", "50")
var batch []*Project
total, err := c.DoPaginated(ctx, "GET", "/projects", q, &batch)
batch, totalPages, err := doList[*Project](ctx, c, "/projects", q)
if err != nil {
return nil, err
}
all = append(all, batch...)
if paginationDone(page, len(batch), 50, total) {
if page >= totalPages {
return all, nil
}
page++
@ -58,7 +57,7 @@ func (c *Client) GetProject(ctx context.Context, id int64) (*Project, error) {
// auto-creates the default views (List, Gantt, Table, Kanban) on insert.
func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error) {
var out Project
if err := c.Do(ctx, "PUT", "/projects", nil, p, &out); err != nil {
if err := c.Do(ctx, "POST", "/projects", nil, p, &out); err != nil {
return nil, err
}
return &out, nil
@ -67,17 +66,20 @@ func (c *Client) CreateProject(ctx context.Context, p *Project) (*Project, error
// ShareProjectWithUser grants `username` `permission` on project `id`.
func (c *Client) ShareProjectWithUser(ctx context.Context, projectID int64, share *ProjectUser) (*ProjectUser, error) {
var out ProjectUser
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil {
if err := c.Do(ctx, "POST", fmt.Sprintf("/projects/%d/users", projectID), nil, share, &out); err != nil {
return nil, err
}
return &out, nil
}
// ListProjectViews returns saved views (Kanban, List, …) on a project.
// ProjectView.ReadAll ignores page/per_page and returns every view in a single
// page, so one GET gets them all — paging would re-fetch the same views and
// duplicate them. Unwrap .items.
func (c *Client) ListProjectViews(ctx context.Context, projectID int64) ([]*ProjectView, error) {
var out []*ProjectView
if err := c.Do(ctx, "GET", fmt.Sprintf("/projects/%d/views", projectID), nil, nil, &out); err != nil {
items, _, err := doList[*ProjectView](ctx, c, fmt.Sprintf("/projects/%d/views", projectID), nil)
if err != nil {
return nil, err
}
return out, nil
return items, nil
}

View File

@ -26,7 +26,7 @@ import (
func (c *Client) CreateRelation(ctx context.Context, taskID int64, otherTaskID int64, relationKind string) (*TaskRelation, error) {
var out TaskRelation
body := &TaskRelation{OtherTaskID: otherTaskID, RelationKind: relationKind}
if err := c.Do(ctx, "PUT", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil {
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d/relations", taskID), nil, body, &out); err != nil {
return nil, err
}
return &out, nil

View File

@ -40,7 +40,7 @@ func (c *Client) Routes(ctx context.Context) (map[string]RouteGroup, error) {
// PermissionsForBot picks a curated subset of route groups the veans bot
// needs and projects the available actions of each. Groups not present on
// the server are silently dropped, so the resulting permission map is
// always valid for PUT /tokens regardless of Vikunja version.
// always valid for POST /tokens regardless of Vikunja version.
//
// The action names reflect Vikunja's actual route map (see GET /routes):
// bucket CRUD and the bucket-task move endpoint live under the `projects`
@ -56,10 +56,16 @@ func PermissionsForBot(routes map[string]RouteGroup) map[string][]string {
},
// Project access: read project metadata, manage buckets & move
// tasks between them. tasks_by-index resolves #NN / PROJ-NN.
// The bucket-task MOVE (PUT .../buckets/:bucket/tasks) and the
// buckets-with-tasks LIST (GET .../buckets/tasks) collide on subkey
// `views_buckets_tasks`; which one gets the bare key vs the
// `_put`-suffixed key depends on unspecified route-init order, so we
// request BOTH and let the runtime intersection drop whichever the
// server didn't register.
"projects": {
"read_one", "read_all", "tasks_by-index",
"views_buckets", "views_buckets_put", "views_buckets_post",
"views_buckets_delete", "views_buckets_tasks",
"views_buckets_delete", "views_buckets_tasks", "views_buckets_tasks_put",
},
"projects_views": {"read_one", "read_all"},
"labels": {"read_one", "read_all", "create", "update", "delete"},

View File

@ -16,7 +16,10 @@
package client
import "testing"
import (
"slices"
"testing"
)
func TestPermissionsForBot_DropsUnknownGroups(t *testing.T) {
// Server only exposes a subset of what we ask for.
@ -61,3 +64,42 @@ func TestPermissionsForBot_EmptyWhenServerIsEmpty(t *testing.T) {
t.Fatalf("expected empty map, got %v", got)
}
}
// TestPermissionsForBot_ProjectsBucketScopes pins the project-group scopes the
// bot needs for the v2 kanban-bucket calls: list/create/update/delete buckets
// plus the bucket-task MOVE. The MOVE and the buckets-with-tasks LIST collide
// on the `views_buckets_tasks` subkey and the bare-vs-_put assignment depends
// on unspecified route-init order, so the bot must request BOTH keys; the
// runtime intersection keeps whichever the server actually exposes.
func TestPermissionsForBot_ProjectsBucketScopes(t *testing.T) {
// A server that registered the move under the bare key and the list under
// the _put key (one of the two possible orderings).
server := map[string]RouteGroup{
"projects": {
"read_one": {},
"read_all": {},
"tasks_by-index": {},
"views_buckets": {}, // list buckets
"views_buckets_post": {}, // create bucket
"views_buckets_put": {}, // update bucket
"views_buckets_delete": {}, // delete bucket
"views_buckets_tasks": {}, // bucket-task move OR buckets-with-tasks list
"views_buckets_tasks_put": {}, // the other of the colliding pair
},
}
got := PermissionsForBot(server)
projects, ok := got["projects"]
if !ok {
t.Fatalf("expected projects group in result")
}
want := []string{
"read_one", "read_all", "tasks_by-index",
"views_buckets", "views_buckets_post", "views_buckets_put",
"views_buckets_delete", "views_buckets_tasks", "views_buckets_tasks_put",
}
for _, w := range want {
if !slices.Contains(projects, w) {
t.Errorf("projects scope %q missing from bot grant; got %v", w, projects)
}
}
}

View File

@ -52,7 +52,7 @@ func (o *TaskListOptions) values() url.Values {
}
// ListProjectTasks paginates `GET /projects/{id}/tasks` exhaustively,
// terminating against the server's x-pagination-total-pages header.
// terminating against the list envelope's total_pages.
func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *TaskListOptions) ([]*Task, error) {
if opts == nil {
opts = &TaskListOptions{}
@ -61,19 +61,19 @@ func (c *Client) ListProjectTasks(ctx context.Context, projectID int64, opts *Ta
if per <= 0 {
per = 50
}
path := fmt.Sprintf("/projects/%d/tasks", projectID)
var all []*Task
page := 1
for {
o := *opts
o.Page = page
o.PerPage = per
var batch []*Task
total, err := c.DoPaginated(ctx, "GET", fmt.Sprintf("/projects/%d/tasks", projectID), o.values(), &batch)
batch, totalPages, err := doList[*Task](ctx, c, path, o.values())
if err != nil {
return nil, err
}
all = append(all, batch...)
if paginationDone(page, len(batch), per, total) {
if page >= totalPages {
return all, nil
}
page++
@ -114,24 +114,26 @@ func (t *Task) CurrentBucketID(viewID int64) int64 {
return 0
}
// CreateTask inserts a task into a project (PUT /projects/{id}/tasks).
// CreateTask inserts a task into a project (POST /projects/{id}/tasks).
func (c *Client) CreateTask(ctx context.Context, projectID int64, t *Task) (*Task, error) {
var out Task
if err := c.Do(ctx, "PUT", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil {
if err := c.Do(ctx, "POST", fmt.Sprintf("/projects/%d/tasks", projectID), nil, t, &out); err != nil {
return nil, err
}
return &out, nil
}
// UpdateTask updates a task (POST /tasks/{id}). This endpoint does NOT
// move tasks between buckets — the task↔bucket relation is row-shaped in
// task_buckets, and bucket_id on the request body is ignored. Use
// MoveTaskToBucket() for that. The server does auto-flip the bucket
// when `done` toggles, but only between the canonical "todo" and "done"
// buckets the project view is configured with.
func (c *Client) UpdateTask(ctx context.Context, id int64, t *Task) (*Task, error) {
// UpdateTask partially updates a task via PATCH /tasks/{id} with a JSON Merge
// Patch body: only the fields set on `patch` are written, the rest are left
// intact (the fix for issue #2962, where a status-only update used to zero
// description and priority). This endpoint does NOT move tasks between
// buckets — the task↔bucket relation is row-shaped in task_buckets, and
// bucket_id on the request body is ignored. Use MoveTaskToBucket() for that.
// The server still auto-flips the bucket when `done` toggles, between the
// canonical "todo" and "done" buckets the project view is configured with.
func (c *Client) UpdateTask(ctx context.Context, id int64, patch *TaskPatch) (*Task, error) {
var out Task
if err := c.Do(ctx, "POST", fmt.Sprintf("/tasks/%d", id), nil, t, &out); err != nil {
if err := c.DoMerge(ctx, "PATCH", fmt.Sprintf("/tasks/%d", id), nil, patch, &out); err != nil {
return nil, err
}
return &out, nil

View File

@ -23,7 +23,7 @@ import "context"
// the bot in step 8 of init).
func (c *Client) CreateToken(ctx context.Context, t *APIToken) (*APIToken, error) {
var out APIToken
if err := c.Do(ctx, "PUT", "/tokens", nil, t, &out); err != nil {
if err := c.Do(ctx, "POST", "/tokens", nil, t, &out); err != nil {
return nil, err
}
return &out, nil

View File

@ -29,7 +29,7 @@ type User struct {
Email string `json:"email,omitempty"`
}
// BotUser is what `PUT /bots` returns.
// BotUser is what `POST /user/bots` returns.
type BotUser struct {
ID int64 `json:"id"`
Username string `json:"username"`
@ -38,7 +38,7 @@ type BotUser struct {
Created time.Time `json:"created,omitempty"`
}
// BotUserCreate is the request body for PUT /bots.
// BotUserCreate is the request body for POST /user/bots.
type BotUserCreate struct {
Username string `json:"username"`
Name string `json:"name,omitempty"`
@ -119,6 +119,20 @@ type Task struct {
PercentDone float64 `json:"percent_done,omitempty"`
}
// TaskPatch is the JSON Merge Patch body for UpdateTask (PATCH /tasks/{id}).
// Every field is a pointer with omitempty so only the fields the caller sets
// are serialized; absent fields are left untouched server-side. This is the
// fix for issue #2962 — a status-only update no longer zeroes description or
// priority the way the old whole-object write did. A non-nil pointer to a zero
// value (e.g. *Priority = 0, *Done = false) still serializes, which is how an
// explicit "clear priority" or "reopen" reaches the server.
type TaskPatch struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
Done *bool `json:"done,omitempty"`
Priority *int64 `json:"priority,omitempty"`
}
// TaskComment matches pkg/models/task_comments.TaskComment.
type TaskComment struct {
ID int64 `json:"id"`
@ -138,12 +152,12 @@ type Label struct {
Updated time.Time `json:"updated,omitempty"`
}
// LabelTask is the body for `PUT /tasks/{id}/labels`.
// LabelTask is the body for `POST /tasks/{id}/labels`.
type LabelTask struct {
LabelID int64 `json:"label_id"`
}
// TaskRelation is the body for `PUT /tasks/{id}/relations` and the row
// TaskRelation is the body for `POST /tasks/{id}/relations` and the row
// returned. RelationKind is one of: subtask, parenttask, related, duplicates,
// duplicateof, blocking, blocked, precedes, follows, copiedfrom, copiedto.
type TaskRelation struct {
@ -152,12 +166,12 @@ type TaskRelation struct {
RelationKind string `json:"relation_kind"`
}
// TaskAssignee is the body for `PUT /tasks/{id}/assignees`.
// TaskAssignee is the body for `POST /tasks/{id}/assignees`.
type TaskAssignee struct {
UserID int64 `json:"user_id"`
}
// ProjectUser is the body and response for `PUT /projects/{id}/users`.
// ProjectUser is the body and response for `POST /projects/{id}/users`.
type ProjectUser struct {
ID int64 `json:"id,omitempty"`
Username string `json:"username"`
@ -171,7 +185,7 @@ const (
PermissionAdmin = 2
)
// APIToken is the request and response shape for `PUT /tokens`. The plaintext
// APIToken is the request and response shape for `POST /tokens`. The plaintext
// `Token` field is only populated on creation. Vikunja requires ExpiresAt;
// callers that want a long-lived token use FarFuture (year 9999).
type APIToken struct {
@ -223,8 +237,9 @@ type LoginResponse struct {
Token string `json:"token"`
}
// OAuthTokenRequest is the JSON body for POST /api/v1/oauth/token. Vikunja's
// OAuth server explicitly rejects form-encoded requests; everything is JSON.
// OAuthTokenRequest is the JSON body for POST /api/v2/oauth/token. The v2
// endpoint accepts both JSON and form-encoded bodies; veans sends JSON, which
// Huma decodes off the Content-Type header regardless of the declared form.
type OAuthTokenRequest struct {
GrantType string `json:"grant_type"`
Code string `json:"code,omitempty"`

View File

@ -23,17 +23,17 @@ import (
"code.vikunja.io/veans/internal/output"
)
// CreateBotUser provisions a bot user via PUT /user/bots. The username must
// CreateBotUser provisions a bot user via POST /user/bots. The username must
// be prefixed `bot-` (Vikunja enforces this). The caller becomes the bot's
// owner, which is what allows them to mint API tokens for the bot via
// PUT /tokens with owner_id.
// POST /tokens with owner_id.
//
// On Vikunja versions that predate the /user/bots endpoint, the server
// returns 404, which we surface as BOT_USERS_UNAVAILABLE so init can fail
// fast with a clear message.
func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*BotUser, error) {
var out BotUser
err := c.Do(ctx, "PUT", "/user/bots", nil, &BotUserCreate{Username: username, Name: name}, &out)
err := c.Do(ctx, "POST", "/user/bots", nil, &BotUserCreate{Username: username, Name: name}, &out)
if err != nil {
var oe *output.Error
if errors.As(err, &oe) && oe.Code == output.CodeNotFound {
@ -45,13 +45,11 @@ func (c *Client) CreateBotUser(ctx context.Context, username, name string) (*Bot
return &out, nil
}
// ListBotUsers returns all bot users owned by the authenticated user.
// ListBotUsers returns all bot users owned by the authenticated user. The v2
// endpoint is server-paginated (BotUser.ReadAll applies a 50-item page limit),
// so page through to the end instead of returning only the first page.
func (c *Client) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
var out []*BotUser
if err := c.Do(ctx, "GET", "/user/bots", nil, nil, &out); err != nil {
return nil, err
}
return out, nil
return doListAll[*BotUser](ctx, c, "/user/bots")
}
// FindMyBotByUsername scans the caller's owned bots for one with the given

View File

@ -37,14 +37,14 @@ func newAPICmd() *cobra.Command {
cmd := &cobra.Command{
Use: "api <METHOD> <PATH>",
Short: "Raw REST passthrough — escape hatch for endpoints veans doesn't wrap",
Long: `Sends a request to /api/v1<PATH> as the bot. Use this when curated
Long: `Sends a request to /api/v2<PATH> as the bot. Use this when curated
commands don't shape the data the way you need. The response body is
written to stdout verbatim.
Examples:
veans api GET /projects
veans api GET /tasks/123
veans api POST /tasks/123 --data '{"description":"updated"}'
veans api POST /tasks/123/comments --data '{"comment":"<p>note</p>"}'
veans api GET /tasks --query expand=reactions --query per_page=100`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {

View File

@ -99,7 +99,7 @@ you want to rotate.`,
return err
}
if minted.Token == "" {
return output.New(output.CodeUnknown, "PUT /tokens did not return token plaintext")
return output.New(output.CodeUnknown, "POST /tokens did not return token plaintext")
}
if err := credentials.Default().Set(cfg.Server, cfg.Bot.Username, minted.Token); err != nil {

View File

@ -88,7 +88,7 @@ func newUpdateCmd() *cobra.Command {
}
// runUpdate is intentionally a single linear flow — the steps it performs
// (concurrency check → status → field changes → comments → field POST
// (concurrency check → status → field changes → comments → field PATCH
// bucket move → label add/remove → refetch) all share the same task,
// flag set, and error-handling shape. Splitting them produces five tiny
// functions that each take the same five arguments.
@ -126,17 +126,18 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
}
}
// Build the update payload incrementally so we don't clobber unmentioned
// fields. The base must include the ID; bucket/done are conditional.
body := &client.Task{ID: id}
// Build the merge-patch payload from only the changed fields. PATCH leaves
// absent fields untouched, so omitting a field preserves it — the id rides
// in the URL, not the body.
body := &client.TaskPatch{}
dirty := false
if f.title != "" {
body.Title = f.title
body.Title = &f.title
dirty = true
}
if f.priorityIsSet {
body.Priority = f.priority
body.Priority = &f.priority
dirty = true
}
@ -147,7 +148,7 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
return nil, err
}
if descChanged {
body.Description = newDesc
body.Description = &newDesc
dirty = true
}
@ -162,7 +163,8 @@ func runUpdate(ctx context.Context, rt *runtime, id int64, f *updateFlags) (*cli
return nil, err
}
bucketTransitionTarget = bid
body.Done = newStatus.Done()
done := newStatus.Done()
body.Done = &done
dirty = true
}

View File

@ -19,6 +19,7 @@ package commands
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"reflect"
@ -155,28 +156,29 @@ func startRecordingServer(t *testing.T) (*httptest.Server, *[]recordedCall) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/tasks/42":
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/42":
// Initial fetch + the final refetch both land here. Return a
// fixed task with an empty label set — labels.go's
// findLabelOnTask only iterates t.Labels.
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 42, "title": "t", "updated": "2026-01-01T00:00:00Z",
})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/comments":
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/tasks/42/comments":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 1, "comment": ""})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/tasks/42":
// UpdateTask. Echo back the id so the encoder downstream is
// happy with a non-nil Task.
case r.Method == http.MethodPatch && r.URL.Path == "/api/v2/tasks/42":
// UpdateTask (merge-patch). Echo back the id so the encoder
// downstream is happy with a non-nil Task.
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"):
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"):
// Bucket-task move (PUT .../buckets/{b}/tasks).
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/labels":
// getOrCreateLabelByTitle's lookup. Empty array → falls through
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/labels":
// getOrCreateLabelByTitle's lookup. Empty envelope → falls through
// to label creation.
_ = json.NewEncoder(w).Encode([]any{})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/labels":
_ = json.NewEncoder(w).Encode(map[string]any{"items": []any{}, "total_pages": 1})
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/labels":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99, "title": "veans:bug"})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/tasks/42/labels":
case r.Method == http.MethodPost && r.URL.Path == "/api/v2/tasks/42/labels":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 99})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
@ -222,11 +224,11 @@ func TestRunUpdate_ScrappedOrdersCommentUpdateMove(t *testing.T) {
}
want := []recordedCall{
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch
{http.MethodPut, "/api/v1/tasks/42/comments"}, // "Scrapped: obsolete"
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=true)
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/14/tasks"}, // bucket move to Scrapped
{http.MethodGet, "/api/v1/tasks/42"}, // refetch with new bucket
{http.MethodGet, "/api/v2/tasks/42"}, // current task fetch
{http.MethodPost, "/api/v2/tasks/42/comments"}, // "Scrapped: obsolete"
{http.MethodPatch, "/api/v2/tasks/42"}, // field update (done=true)
{http.MethodPut, "/api/v2/projects/7/views/1/buckets/14/tasks"}, // bucket move to Scrapped
{http.MethodGet, "/api/v2/tasks/42"}, // refetch with new bucket
}
if !reflect.DeepEqual(*calls, want) {
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)
@ -253,15 +255,108 @@ func TestRunUpdate_BucketMoveBeforeLabelAdd(t *testing.T) {
}
want := []recordedCall{
{http.MethodGet, "/api/v1/tasks/42"}, // current task fetch
{http.MethodPost, "/api/v1/tasks/42"}, // field update (done=false)
{http.MethodPost, "/api/v1/projects/7/views/1/buckets/11/tasks"}, // bucket move to In Progress
{http.MethodGet, "/api/v1/labels"}, // getOrCreateLabelByTitle lookup
{http.MethodPut, "/api/v1/labels"}, // create veans:bug
{http.MethodPut, "/api/v1/tasks/42/labels"}, // attach label
{http.MethodGet, "/api/v1/tasks/42"}, // refetch
{http.MethodGet, "/api/v2/tasks/42"}, // current task fetch
{http.MethodPatch, "/api/v2/tasks/42"}, // field update (done=false)
{http.MethodPut, "/api/v2/projects/7/views/1/buckets/11/tasks"}, // bucket move to In Progress
{http.MethodGet, "/api/v2/labels"}, // getOrCreateLabelByTitle lookup
{http.MethodPost, "/api/v2/labels"}, // create veans:bug
{http.MethodPost, "/api/v2/tasks/42/labels"}, // attach label
{http.MethodGet, "/api/v2/tasks/42"}, // refetch
}
if !reflect.DeepEqual(*calls, want) {
t.Fatalf("call order mismatch:\nwant: %#v\ngot: %#v", want, *calls)
}
}
// startPatchCapturingServer answers the endpoints a field-only runUpdate
// touches (no labels) and records the raw JSON body of the merge-patch
// PATCH /tasks/{id} request so a test can assert exactly which fields were
// sent. The seeded task carries a description and priority that a partial
// update must leave untouched (issue #2962).
func startPatchCapturingServer(t *testing.T) (*httptest.Server, *[]byte) {
t.Helper()
var (
mu sync.Mutex
patchBody []byte
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/tasks/42":
_ = json.NewEncoder(w).Encode(map[string]any{
"id": 42, "title": "t", "description": "keep me",
"priority": 4, "updated": "2026-01-01T00:00:00Z",
})
case r.Method == http.MethodPatch && r.URL.Path == "/api/v2/tasks/42":
b, _ := io.ReadAll(r.Body)
mu.Lock()
patchBody = b
mu.Unlock()
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/projects/") && strings.HasSuffix(r.URL.Path, "/tasks"):
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42})
default:
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
http.Error(w, "unexpected", http.StatusInternalServerError)
}
}))
t.Cleanup(srv.Close)
return srv, &patchBody
}
// TestRunUpdate_TitleOnlyPatchOmitsOtherFields is the #2962 acceptance test for
// a title-only update: the merge-patch body must contain ONLY title, so the
// stored description/priority/done are left intact server-side.
func TestRunUpdate_TitleOnlyPatchOmitsOtherFields(t *testing.T) {
srv, patchBody := startPatchCapturingServer(t)
rt := newTestRuntime(srv.URL)
if _, err := runUpdate(context.Background(), rt, 42, &updateFlags{title: "new title"}); err != nil {
t.Fatalf("runUpdate: %v", err)
}
body := decodePatchBody(t, *patchBody)
if body["title"] != "new title" {
t.Errorf("patch should set title; got %v", body["title"])
}
for _, k := range []string{"description", "priority", "done"} {
if _, present := body[k]; present {
t.Errorf("title-only update must not send %q (merge-patch would clobber it); body=%s", k, *patchBody)
}
}
}
// TestRunUpdate_StatusOnlyPatchPreservesDescriptionAndPriority is the #2962
// acceptance test for a status-only update: the merge-patch body carries the
// done flag (and nothing else), so description and priority survive — the
// regression the whole-object POST caused.
func TestRunUpdate_StatusOnlyPatchPreservesDescriptionAndPriority(t *testing.T) {
srv, patchBody := startPatchCapturingServer(t)
rt := newTestRuntime(srv.URL)
if _, err := runUpdate(context.Background(), rt, 42, &updateFlags{statusName: "in-progress"}); err != nil {
t.Fatalf("runUpdate: %v", err)
}
body := decodePatchBody(t, *patchBody)
if d, ok := body["done"].(bool); !ok || d {
t.Errorf("in-progress status should send done=false; got %v", body["done"])
}
for _, k := range []string{"description", "priority", "title"} {
if _, present := body[k]; present {
t.Errorf("status-only update must not send %q (#2962: it would clobber the stored value); body=%s", k, *patchBody)
}
}
}
func decodePatchBody(t *testing.T, raw []byte) map[string]any {
t.Helper()
if len(raw) == 0 {
t.Fatal("no PATCH body was captured")
}
var body map[string]any
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode patch body %q: %v", string(raw), err)
}
return body
}