From bf175dde6db559ac4e4ae374d69eafdd4471c87f Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 10:09:00 +0200 Subject: [PATCH 01/40] fix(kanban): upsert race condition in kanban task bucket sync (#2938) --- pkg/models/kanban_task_bucket.go | 38 +++++++----------- pkg/models/kanban_task_bucket_test.go | 57 +++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/pkg/models/kanban_task_bucket.go b/pkg/models/kanban_task_bucket.go index ae6757b30..cd58e6928 100644 --- a/pkg/models/kanban_task_bucket.go +++ b/pkg/models/kanban_task_bucket.go @@ -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 } diff --git a/pkg/models/kanban_task_bucket_test.go b/pkg/models/kanban_task_bucket_test.go index 7bde2ade4..bf5c42ac3 100644 --- a/pkg/models/kanban_task_bucket_test.go +++ b/pkg/models/kanban_task_bucket_test.go @@ -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 From f3c6312a9ec0695305cf3c5b637175ef27cea463 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 10:15:58 +0200 Subject: [PATCH 02/40] feat(projects): make duplicating shares opt-in (#2932) --- frontend/src/i18n/lang/en.json | 1 + frontend/src/modelTypes/IProjectDuplicate.ts | 1 + frontend/src/models/projectDuplicateModel.ts | 1 + frontend/src/stores/projects.ts | 3 +- .../settings/ProjectSettingsDuplicate.vue | 10 +- pkg/models/project_duplicate.go | 98 ++++++++++--------- pkg/models/project_duplicate_test.go | 52 +++++++++- pkg/routes/api/v2/project_duplicate.go | 2 +- 8 files changed, 117 insertions(+), 51 deletions(-) diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index a48d793ac..82e7d0e5a 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -393,6 +393,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": { diff --git a/frontend/src/modelTypes/IProjectDuplicate.ts b/frontend/src/modelTypes/IProjectDuplicate.ts index a24efa58c..cf9ba9167 100644 --- a/frontend/src/modelTypes/IProjectDuplicate.ts +++ b/frontend/src/modelTypes/IProjectDuplicate.ts @@ -5,4 +5,5 @@ export interface IProjectDuplicate extends IAbstract { projectId: number duplicatedProject: IProject | null parentProjectId: IProject['id'] + duplicateShares: boolean } diff --git a/frontend/src/models/projectDuplicateModel.ts b/frontend/src/models/projectDuplicateModel.ts index ac137714d..53af125ba 100644 --- a/frontend/src/models/projectDuplicateModel.ts +++ b/frontend/src/models/projectDuplicateModel.ts @@ -8,6 +8,7 @@ export default class ProjectDuplicateModel extends AbstractModel) { super() diff --git a/frontend/src/stores/projects.ts b/frontend/src/stores/projects.ts index 37110100e..18a3dfad2 100644 --- a/frontend/src/stores/projects.ts +++ b/frontend/src/stores/projects.ts @@ -380,10 +380,11 @@ export function useProject(projectId: MaybeRefOrGetter) { 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) diff --git a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue index 764a44803..ebfc61003 100644 --- a/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue +++ b/frontend/src/views/project/settings/ProjectSettingsDuplicate.vue @@ -8,6 +8,12 @@ >

{{ $t('project.duplicate.text') }}

+ + {{ $t('project.duplicate.shares') }} + @@ -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(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 diff --git a/pkg/models/project_duplicate.go b/pkg/models/project_duplicate.go index 947b07d5a..6b7c2f87d 100644 --- a/pkg/models/project_duplicate.go +++ b/pkg/models/project_duplicate.go @@ -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 diff --git a/pkg/models/project_duplicate_test.go b/pkg/models/project_duplicate_test.go index a89342edb..5e3360a1b 100644 --- a/pkg/models/project_duplicate_test.go +++ b/pkg/models/project_duplicate_test.go @@ -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) diff --git a/pkg/routes/api/v2/project_duplicate.go b/pkg/routes/api/v2/project_duplicate.go index 9fd23798f..6a050b2c2 100644 --- a/pkg/routes/api/v2/project_duplicate.go +++ b/pkg/routes/api/v2/project_duplicate.go @@ -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, From 822fde25942a94db90833686f313c5fb096c2c0b Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Fri, 19 Jun 2026 08:34:37 +0000 Subject: [PATCH 03/40] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 6 +++++- pkg/swagger/swagger.json | 6 +++++- pkg/swagger/swagger.yaml | 10 +++++++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index e976b4d74..36fee319d 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -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" ], @@ -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": [ diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 98f528565..a20519dcd 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -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" ], @@ -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": [ diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index a926e18b8..515ff828c 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -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' @@ -4703,9 +4706,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 From 6e1b15e34486314ed5617da432063fe89b303a87 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 15:58:02 +0200 Subject: [PATCH 04/40] fix(tasks): add labels sequentially when the backend db serializes writes Quick Add Magic with multiple labels (`*a *b *c`) fired all `PUT /tasks/{id}/labels` requests concurrently via `Promise.all`. On SQLite these overlap as read-then-write upgrade transactions, which the busy_timeout can't resolve, so some requests fail with HTTP 500 ("database is locked") and the labels are silently dropped while the quick-add input gets stuck. Expose a `concurrent_writes` flag on the shared `/info` response (true for Postgres/MySQL, false for SQLite). The frontend config store reads it and `addLabelsToTask` now branches: parallel `Promise.all` when the backend supports concurrent writes, sequential awaits otherwise. Fixes #2680 --- frontend/src/stores/config.ts | 2 ++ frontend/src/stores/tasks.test.ts | 38 ++++++++++++++++++++++++++++++- frontend/src/stores/tasks.ts | 23 +++++++++++++++---- pkg/routes/api/shared/info.go | 3 +++ pkg/webtests/huma_info_test.go | 5 ++++ 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/frontend/src/stores/config.ts b/frontend/src/stores/config.ts index 3eea0595a..89b57bcf1 100644 --- a/frontend/src/stores/config.ts +++ b/frontend/src/stores/config.ts @@ -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) diff --git a/frontend/src/stores/tasks.test.ts b/frontend/src/stores/tasks.test.ts index 4d7a60af6..644d6e6bb 100644 --- a/frontend/src/stores/tasks.test.ts +++ b/frontend/src/stores/tasks.test.ts @@ -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) + }) +}) diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 3eb99d1ef..ac27ff0d2 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -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( + items: readonly T[], + write: (item: T) => Promise, + concurrent: boolean, +): Promise { + 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 } diff --git a/pkg/routes/api/shared/info.go b/pkg/routes/api/shared/info.go index 423aae2c7..48cbb00a5 100644 --- a/pkg/routes/api/shared/info.go +++ b/pkg/routes/api/shared/info.go @@ -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(), diff --git a/pkg/webtests/huma_info_test.go b/pkg/webtests/huma_info_test.go index 5ba7f859c..d9f2684a9 100644 --- a/pkg/webtests/huma_info_test.go +++ b/pkg/webtests/huma_info_test.go @@ -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"]) } From adf031128e230ea3cde52917a23fe91388322757 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Fri, 19 Jun 2026 14:51:16 +0000 Subject: [PATCH 05/40] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 4 ++++ pkg/swagger/swagger.json | 4 ++++ pkg/swagger/swagger.yaml | 6 ++++++ 3 files changed, 14 insertions(+) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 36fee319d..fb3dfccdc 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -10906,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" }, diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index a20519dcd..4fc8cfe9a 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -10898,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" }, diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index 515ff828c..de0e5fbe2 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1537,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: From 767ce3bc7ee0b88ac240867d0363bf2e68573099 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 16:54:20 +0200 Subject: [PATCH 06/40] fix(tasks): reset description checklist when a recurring task recurs (#2941) --- pkg/models/tasks.go | 19 +++++++++++++++++++ pkg/models/tasks_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ec6200c48..978a0f850 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1747,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) { newTask.Done = false } +var ( + checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`) + checklistInputCheckedRegex = regexp.MustCompile(`(]*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() } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 7219b5ab8..433bee18a 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -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
  • Item

after` + const unchecked = `before
  • Item

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
  • Item

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) + }) }) } From 54fbc79a52d0fac75b38c3e0de9fae7673555788 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:38:43 +0000 Subject: [PATCH 07/40] chore(deps): update dev-dependencies to v4.62.1 --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 309 ++++++++++++++++++---------------------- 2 files changed, 138 insertions(+), 173 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index b63579377..f54d2ed79 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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.1", "rollup-plugin-visualizer": "6.0.11", "sass-embedded": "1.100.0", "stylelint": "17.13.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 1059ecf19..997ec9709 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: minimatch: ^10.2.3 - rollup: 4.62.0 + rollup: 4.62.1 basic-ftp: '>=5.2.2' serialize-javascript: ^7.0.5 flatted: ^3.4.1 @@ -41,7 +41,7 @@ importers: version: 3.1.3(@fortawesome/fontawesome-svg-core@7.1.0)(vue@3.5.27(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: 11.0.3 - version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) '@kyvg/vue3-notification': specifier: 3.4.2 version: 3.4.2(vue@3.5.27(typescript@5.9.3)) @@ -284,11 +284,11 @@ importers: specifier: 11.3.1 version: 11.3.1(postcss@8.5.14) rollup: - specifier: 4.62.0 - version: 4.62.0 + specifier: 4.62.1 + version: 4.62.1 rollup-plugin-visualizer: specifier: 6.0.11 - version: 6.0.11(rollup@4.62.0) + version: 6.0.11(rollup@4.62.1) sass-embedded: specifier: 1.100.0 version: 1.100.0 @@ -1828,42 +1828,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} @@ -1946,7 +1940,7 @@ packages: peerDependencies: '@babel/core': '>=7.29.6' '@types/babel__core': ^7.1.9 - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: '@types/babel__core': optional: true @@ -1957,7 +1951,7 @@ packages: resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: rollup: optional: true @@ -1966,7 +1960,7 @@ packages: resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: rollup: optional: true @@ -1975,7 +1969,7 @@ packages: resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} engines: {node: '>=20.0.0'} peerDependencies: - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: rollup: optional: true @@ -1984,146 +1978,133 @@ packages: resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.62.0': - resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + '@rollup/rollup-android-arm-eabi@4.62.1': + resolution: {integrity: sha512-WUtumI+yIc7YXY3ZtN68V50CHEjgopo0rIZ90+ZqlZzIGroVn3qkfK7wkdl+HebaxenGQMrlB/KJs+aLMZg9lQ==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.0': - resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + '@rollup/rollup-android-arm64@4.62.1': + resolution: {integrity: sha512-ivTbxKROae184UB9SNQGOmXCwdgq1rb1OfDOXHOw9bHHVtoUSQoyLwAgxcd9zlef+vtPnyqN22HrYvaI7K12Zw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.0': - resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + '@rollup/rollup-darwin-arm64@4.62.1': + resolution: {integrity: sha512-+nRm4AIocYcaE5yP07KGybXGDGfBCXOSY7EE7GeGvA8rzK+eiZteAgn9VNkn8sw/+FWR+9FLyph0gUNuY75KuQ==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.0': - resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + '@rollup/rollup-darwin-x64@4.62.1': + resolution: {integrity: sha512-63zVs6JwE9i3BMhHm1Gi5+LP8dRKQVrD5UzgjDgZfptON38vfStA4iAK0DpxqTmI8udUzr1Qwk1tEhLRcj7PVA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.0': - resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + '@rollup/rollup-freebsd-arm64@4.62.1': + resolution: {integrity: sha512-uXASB7+/ZbR7q4RC35T/xTwQt4Qwt8e1my8E7hI6PxaQxuNiuvM+B/I58xvJLaVYOmCGy9cu3Ky1SSY4ia/G0Q==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.0': - resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + '@rollup/rollup-freebsd-x64@4.62.1': + resolution: {integrity: sha512-BqeibWSAOg/6bwxDnJ1Z4806jc6kIuGYCDS52DY4u23EgcK3DMrm4rrODmPTltA8EFlvhz2gXGhs/RwgWuto/w==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': - resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.1': + resolution: {integrity: sha512-9ryebRuEJ1OcKl9ZWWyXZ84OrpqXl8qwa99ZwrVn1uzBu9TwNqpyoScK7yF/+WoHW0dBGUR3tAHem7nWP1ismQ==} cpu: [arm] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm-musleabihf@4.62.0': - resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + '@rollup/rollup-linux-arm-musleabihf@4.62.1': + resolution: {integrity: sha512-HOHv0qumBDTLxM/j5nE2X6SVHGK2F5r211WqFn0PB+lJL3o4HBP9CsjlcdwIk6aILYeRveltSVmvv9NSW3vnWg==} cpu: [arm] os: [linux] - libc: [musl] - '@rollup/rollup-linux-arm64-gnu@4.62.0': - resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + '@rollup/rollup-linux-arm64-gnu@4.62.1': + resolution: {integrity: sha512-jrDLxV5iWL8fdpj5N5+9ZAd2BjD3U6h1eiVhOCDQhvKG+C0uJt3phgIsS7sWKTk4LLaom87dMJCIXnakXEs4fA==} cpu: [arm64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-arm64-musl@4.62.0': - resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + '@rollup/rollup-linux-arm64-musl@4.62.1': + resolution: {integrity: sha512-kaKe83aR+a5bvGTdXFlUzGUFPHoSm2zo1PFalUuwqj7+txbLm4jyXwM4IkmrEWK9yAWE9qO654XuBb8dqgSP4A==} cpu: [arm64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-loong64-gnu@4.62.0': - resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + '@rollup/rollup-linux-loong64-gnu@4.62.1': + resolution: {integrity: sha512-tp+VgVhkZ9iNDGezXQnBx0h+ZraZJCKtbrsxGRSO3Y+Ta/YrUfLxlKXU4IiBm9AWlj9EDH1Djrvsl6ledeUdJg==} cpu: [loong64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-loong64-musl@4.62.0': - resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + '@rollup/rollup-linux-loong64-musl@4.62.1': + resolution: {integrity: sha512-LN9invzRf8ejduiGlrtr46Gk08Uh/1eiMMLgo/CNPHeRpYH8EYW6YQuAqkoxItk+Rtmod1raQ8W49sO+hP+6hQ==} cpu: [loong64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-ppc64-gnu@4.62.0': - resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + '@rollup/rollup-linux-ppc64-gnu@4.62.1': + resolution: {integrity: sha512-F2Abce1ndQR8UXEX8Bj3EFd5jlw/u0rlbjmsEzBPty/YJ8H57x3POPnBxr7Mbi8m7UNwukwFW6Z20I+hrQvWdQ==} cpu: [ppc64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-ppc64-musl@4.62.0': - resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + '@rollup/rollup-linux-ppc64-musl@4.62.1': + resolution: {integrity: sha512-nmJq25UletS/fI3icrKsBH8KDkTf7cSGTY5bkWI9z3+4oHj1DxHQkWCP8uP7m+AEhc1fc73AcycZam4iViAoNQ==} cpu: [ppc64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-riscv64-gnu@4.62.0': - resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + '@rollup/rollup-linux-riscv64-gnu@4.62.1': + resolution: {integrity: sha512-HqWXZHGXFrKmSs3qOmNBfLY34CzYDt3HU2oQq2cplmU1gEADa2dWf6xcjrQuHYbNYZpJY2+rLNAbHyXtrO/0PQ==} cpu: [riscv64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-riscv64-musl@4.62.0': - resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + '@rollup/rollup-linux-riscv64-musl@4.62.1': + resolution: {integrity: sha512-xYDVRyJEbrzr14Z2hqe59C1pwosdl9Td0ik5gu5x85mVswTweg492as4Vzs/8zKkvvUgO5VdGRL7OzN+W9Z6+w==} cpu: [riscv64] os: [linux] - libc: [musl] - '@rollup/rollup-linux-s390x-gnu@4.62.0': - resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + '@rollup/rollup-linux-s390x-gnu@4.62.1': + resolution: {integrity: sha512-X6n4yZUYAGSZTsIRjHUFkRZy/ml+EyS5vsgnyUOfhflKros0TEjX9yAoFqiRdJSfmykStVUyfcFDy/tHJ64JuQ==} cpu: [s390x] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-gnu@4.62.0': - resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + '@rollup/rollup-linux-x64-gnu@4.62.1': + resolution: {integrity: sha512-nVQGk/jStQc2V4rrkI+vPD2J+85boKqS4R4nOdPhc3eWw0kyW/b+AYRGoH8qo057XSVqaTx13AliH5qPeLTtgQ==} cpu: [x64] os: [linux] - libc: [glibc] - '@rollup/rollup-linux-x64-musl@4.62.0': - resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + '@rollup/rollup-linux-x64-musl@4.62.1': + resolution: {integrity: sha512-Ae54IyMwpY3JsYjBH4k29vQ9FSoILwJdh7j7c9lmLOczKnU/WL5jMRL9epsgPrs+ph48YVTsy6PkQDq0nK8Kvg==} cpu: [x64] os: [linux] - libc: [musl] - '@rollup/rollup-openbsd-x64@4.62.0': - resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + '@rollup/rollup-openbsd-x64@4.62.1': + resolution: {integrity: sha512-7oOS0UqUXLRi2dVeEXdQxbml854xxQSx+6Pdnuo4G0iAIRiPBCIyzhLIv8oSmvqLkAftGaRk+ft70fVHXjsXsQ==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.0': - resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + '@rollup/rollup-openharmony-arm64@4.62.1': + resolution: {integrity: sha512-e6kAhhmUK3pwICnBtsQFkg/czVxFlY5e4Ppi4fuXWvOwiHOXlgQMEvpg0H5ceuEh2T1nyI0U6SfhV3qojKWpAg==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.0': - resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + '@rollup/rollup-win32-arm64-msvc@4.62.1': + resolution: {integrity: sha512-xXRJSv00uVmj5DwS9DwIvS+Re5VdDnaspDfk7GzsnhP1IbTzFjJwhY+c3j3jr/2pP/prBrXvZ1OmjjhkkAOUlQ==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.0': - resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + '@rollup/rollup-win32-ia32-msvc@4.62.1': + resolution: {integrity: sha512-D3S8+6cSEW0QZZHcKKDQ/Fsz/eqvYmJbtkZZziFxEb4Fi4fyWTCaMs1p5siQ85/T6gNdYKJ3OIJ4M/phYQgICA==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.0': - resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + '@rollup/rollup-win32-x64-gnu@4.62.1': + resolution: {integrity: sha512-CRVGPQKdEB/ujGfrq3SgITWc2N9iWM+sqaBKHh62Dc6xRLQGTVrqHpOVEitfly941kr244j14sswRw47bmMjjg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.0': - resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + '@rollup/rollup-win32-x64-msvc@4.62.1': + resolution: {integrity: sha512-/N8QHE1y6A9nmN3HCIFZWr5FUu/rKcT/A7JgaMJH3dcvL5RS++o0brK5SitYVTis/dJFiasK7Xva0cqeWYmCzQ==} cpu: [x64] os: [win32] @@ -2298,28 +2279,24 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.1': resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] - libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.1': resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.1': resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] - libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.1': resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} @@ -4663,28 +4640,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -5589,15 +5562,15 @@ packages: hasBin: true peerDependencies: rolldown: 1.x || ^1.0.0-beta - rollup: 4.62.0 + rollup: 4.62.1 peerDependenciesMeta: rolldown: optional: true rollup: optional: true - rollup@4.62.0: - resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + rollup@4.62.1: + resolution: {integrity: sha512-XTvxjHHM/0J/WZBg+ehDbAZgIpZoIZtWO+aImyuhjoyQa56NBX/bqnXw32rT27fkjSRrqthOgkLjRVtwXFI7jQ==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -5689,56 +5662,48 @@ packages: engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: glibc sass-embedded-linux-arm@1.100.0: resolution: {integrity: sha512-9Ul7O1eKrc5YlhwWjkp8tZPSe3UEwSZ1uwUZOQom1HL0pRlBA6F/IlGZYFTLwnHMIP1fc77MMNaBRfc05mKMpw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: glibc sass-embedded-linux-musl-arm64@1.100.0: resolution: {integrity: sha512-XpACJB2KjSLjf2e9uuvGVdOURsoNrFqgRiihhXyUHK9W0t3LIHb7z5MA/7XGPIT9bWSOO2zyw+rH/FHtDV/Yrg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - libc: musl sass-embedded-linux-musl-arm@1.100.0: resolution: {integrity: sha512-sl0JgbGloPyJg66XXx5UDSDScZ0oU85DpMQU4JU/sCUCFj1Z8zZ69SJWKTCNE4/jwnce7WI2zPCV5AG+RHOZJw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - libc: musl sass-embedded-linux-musl-riscv64@1.100.0: resolution: {integrity: sha512-ShvI0Kx04mwoCARwZ0UjiT97isQvzO80tAt91zmFyHLN9kelc/IrQi940farSm2xQVPCKdeVyeG0ekBsokSpYQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: musl sass-embedded-linux-musl-x64@1.100.0: resolution: {integrity: sha512-TDBCRWNuS4RDLQXvRc1gjZlWiWTWaWGp0Bwu/IKwJxov81lsvrCs3TihTyNXtW7V5aoN4Ky3r0QOkNb3mwmBnA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: musl sass-embedded-linux-riscv64@1.100.0: resolution: {integrity: sha512-j4ENJGOheO+fm3j/yorLxCjBP6/XskrZx7dTLlT+lXYwN/qqCqoA/gsNLI0McS3DFM6GBwPiffzWsdWS8t6sEQ==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - libc: glibc sass-embedded-linux-x64@1.100.0: resolution: {integrity: sha512-0vUSN8j0WGtCJIOPh//EmUvYGHW0QOe5iul8qyhPk50MAcw49MA0r34AhftjDdx94ILPF6vApFs0gwHPQRlpVA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - libc: glibc sass-embedded-unknown-all@1.100.0: resolution: {integrity: sha512-c+naBgWId4MIpToXcI0DgqetjdAkwTTAxFAuOaBz7HUXLdyG1oZRrEvSsbe41nEdQOKH0vgofVFCeSQgoXOG9A==} @@ -8388,13 +8353,13 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.0)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3))) '@intlify/shared': 11.2.8 '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.27)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) - '@rollup/pluginutils': 5.1.3(rollup@4.62.0) + '@rollup/pluginutils': 5.1.3(rollup@4.62.1) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) debug: 4.4.3 @@ -8638,122 +8603,122 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.0)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.1)': dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 - '@rollup/pluginutils': 5.1.3(rollup@4.62.0) + '@rollup/pluginutils': 5.1.3(rollup@4.62.1) optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 transitivePeerDependencies: - supports-color - '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.0)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.1)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.62.0) + '@rollup/pluginutils': 5.1.3(rollup@4.62.1) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 - '@rollup/plugin-replace@6.0.3(rollup@4.62.0)': + '@rollup/plugin-replace@6.0.3(rollup@4.62.1)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.62.0) + '@rollup/pluginutils': 5.1.3(rollup@4.62.1) magic-string: 0.30.21 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 - '@rollup/plugin-terser@1.0.0(rollup@4.62.0)': + '@rollup/plugin-terser@1.0.0(rollup@4.62.1)': dependencies: serialize-javascript: 7.0.5 smob: 1.5.0 terser: 5.31.6 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 - '@rollup/pluginutils@5.1.3(rollup@4.62.0)': + '@rollup/pluginutils@5.1.3(rollup@4.62.1)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 - '@rollup/rollup-android-arm-eabi@4.62.0': + '@rollup/rollup-android-arm-eabi@4.62.1': optional: true - '@rollup/rollup-android-arm64@4.62.0': + '@rollup/rollup-android-arm64@4.62.1': optional: true - '@rollup/rollup-darwin-arm64@4.62.0': + '@rollup/rollup-darwin-arm64@4.62.1': optional: true - '@rollup/rollup-darwin-x64@4.62.0': + '@rollup/rollup-darwin-x64@4.62.1': optional: true - '@rollup/rollup-freebsd-arm64@4.62.0': + '@rollup/rollup-freebsd-arm64@4.62.1': optional: true - '@rollup/rollup-freebsd-x64@4.62.0': + '@rollup/rollup-freebsd-x64@4.62.1': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + '@rollup/rollup-linux-arm-gnueabihf@4.62.1': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.0': + '@rollup/rollup-linux-arm-musleabihf@4.62.1': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.0': + '@rollup/rollup-linux-arm64-gnu@4.62.1': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.0': + '@rollup/rollup-linux-arm64-musl@4.62.1': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.0': + '@rollup/rollup-linux-loong64-gnu@4.62.1': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.0': + '@rollup/rollup-linux-loong64-musl@4.62.1': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.0': + '@rollup/rollup-linux-ppc64-gnu@4.62.1': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.0': + '@rollup/rollup-linux-ppc64-musl@4.62.1': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.0': + '@rollup/rollup-linux-riscv64-gnu@4.62.1': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.0': + '@rollup/rollup-linux-riscv64-musl@4.62.1': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.0': + '@rollup/rollup-linux-s390x-gnu@4.62.1': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.0': + '@rollup/rollup-linux-x64-gnu@4.62.1': optional: true - '@rollup/rollup-linux-x64-musl@4.62.0': + '@rollup/rollup-linux-x64-musl@4.62.1': optional: true - '@rollup/rollup-openbsd-x64@4.62.0': + '@rollup/rollup-openbsd-x64@4.62.1': optional: true - '@rollup/rollup-openharmony-arm64@4.62.0': + '@rollup/rollup-openharmony-arm64@4.62.1': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.0': + '@rollup/rollup-win32-arm64-msvc@4.62.1': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.0': + '@rollup/rollup-win32-ia32-msvc@4.62.1': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.0': + '@rollup/rollup-win32-x64-gnu@4.62.1': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.0': + '@rollup/rollup-win32-x64-msvc@4.62.1': optional: true '@sentry-internal/browser-utils@10.36.0': @@ -12575,44 +12540,44 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.11(rollup@4.62.0): + rollup-plugin-visualizer@6.0.11(rollup@4.62.1): dependencies: open: 8.4.2 picomatch: 4.0.4 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.62.0 + rollup: 4.62.1 - rollup@4.62.0: + rollup@4.62.1: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.0 - '@rollup/rollup-android-arm64': 4.62.0 - '@rollup/rollup-darwin-arm64': 4.62.0 - '@rollup/rollup-darwin-x64': 4.62.0 - '@rollup/rollup-freebsd-arm64': 4.62.0 - '@rollup/rollup-freebsd-x64': 4.62.0 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 - '@rollup/rollup-linux-arm-musleabihf': 4.62.0 - '@rollup/rollup-linux-arm64-gnu': 4.62.0 - '@rollup/rollup-linux-arm64-musl': 4.62.0 - '@rollup/rollup-linux-loong64-gnu': 4.62.0 - '@rollup/rollup-linux-loong64-musl': 4.62.0 - '@rollup/rollup-linux-ppc64-gnu': 4.62.0 - '@rollup/rollup-linux-ppc64-musl': 4.62.0 - '@rollup/rollup-linux-riscv64-gnu': 4.62.0 - '@rollup/rollup-linux-riscv64-musl': 4.62.0 - '@rollup/rollup-linux-s390x-gnu': 4.62.0 - '@rollup/rollup-linux-x64-gnu': 4.62.0 - '@rollup/rollup-linux-x64-musl': 4.62.0 - '@rollup/rollup-openbsd-x64': 4.62.0 - '@rollup/rollup-openharmony-arm64': 4.62.0 - '@rollup/rollup-win32-arm64-msvc': 4.62.0 - '@rollup/rollup-win32-ia32-msvc': 4.62.0 - '@rollup/rollup-win32-x64-gnu': 4.62.0 - '@rollup/rollup-win32-x64-msvc': 4.62.0 + '@rollup/rollup-android-arm-eabi': 4.62.1 + '@rollup/rollup-android-arm64': 4.62.1 + '@rollup/rollup-darwin-arm64': 4.62.1 + '@rollup/rollup-darwin-x64': 4.62.1 + '@rollup/rollup-freebsd-arm64': 4.62.1 + '@rollup/rollup-freebsd-x64': 4.62.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.1 + '@rollup/rollup-linux-arm-musleabihf': 4.62.1 + '@rollup/rollup-linux-arm64-gnu': 4.62.1 + '@rollup/rollup-linux-arm64-musl': 4.62.1 + '@rollup/rollup-linux-loong64-gnu': 4.62.1 + '@rollup/rollup-linux-loong64-musl': 4.62.1 + '@rollup/rollup-linux-ppc64-gnu': 4.62.1 + '@rollup/rollup-linux-ppc64-musl': 4.62.1 + '@rollup/rollup-linux-riscv64-gnu': 4.62.1 + '@rollup/rollup-linux-riscv64-musl': 4.62.1 + '@rollup/rollup-linux-s390x-gnu': 4.62.1 + '@rollup/rollup-linux-x64-gnu': 4.62.1 + '@rollup/rollup-linux-x64-musl': 4.62.1 + '@rollup/rollup-openbsd-x64': 4.62.1 + '@rollup/rollup-openharmony-arm64': 4.62.1 + '@rollup/rollup-win32-arm64-msvc': 4.62.1 + '@rollup/rollup-win32-ia32-msvc': 4.62.1 + '@rollup/rollup-win32-x64-gnu': 4.62.1 + '@rollup/rollup-win32-x64-msvc': 4.62.1 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -13615,7 +13580,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 - rollup: 4.62.0 + rollup: 4.62.1 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.13.2 @@ -13855,10 +13820,10 @@ snapshots: '@babel/core': 7.29.7 '@babel/preset-env': 7.26.0(@babel/core@7.29.7) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.0) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.0) - '@rollup/plugin-replace': 6.0.3(rollup@4.62.0) - '@rollup/plugin-terser': 1.0.0(rollup@4.62.0) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.1) + '@rollup/plugin-terser': 1.0.0(rollup@4.62.1) '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 ajv: 8.18.0 common-tags: 1.8.2 @@ -13867,7 +13832,7 @@ snapshots: fs-extra: 9.1.0 glob: 11.1.0 pretty-bytes: 5.6.0 - rollup: 4.62.0 + rollup: 4.62.1 source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 From 7208694960b55261fa480e5a880596a922ff9fd0 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 18:27:33 +0200 Subject: [PATCH 08/40] fix(auth): build OIDC end-session URL with RP-Initiated Logout params (#2943) --- frontend/src/stores/auth.ts | 11 +- pkg/migration/20260619155410.go | 55 ++++++ pkg/models/sessions.go | 18 +- pkg/modules/auth/auth.go | 12 +- pkg/modules/auth/oauth2server/token.go | 2 +- pkg/modules/auth/openid/logout.go | 110 ++++++++++++ pkg/modules/auth/openid/logout_test.go | 234 +++++++++++++++++++++++++ pkg/modules/auth/openid/openid.go | 58 +++--- pkg/modules/auth/openid/providers.go | 25 +++ pkg/routes/api/shared/auth.go | 38 +++- pkg/routes/api/v1/login.go | 20 ++- pkg/routes/api/v2/auth_login.go | 9 +- pkg/routes/api/v2/auth_openid.go | 4 +- pkg/webtests/huma_auth_login_test.go | 2 +- 14 files changed, 550 insertions(+), 48 deletions(-) create mode 100644 pkg/migration/20260619155410.go create mode 100644 pkg/modules/auth/openid/logout.go create mode 100644 pkg/modules/auth/openid/logout_test.go diff --git a/frontend/src/stores/auth.ts b/frontend/src/stores/auth.ts index e2576760f..e9a7c8ee0 100644 --- a/frontend/src/stores/auth.ts +++ b/frontend/src/stores/auth.ts @@ -533,9 +533,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 } @@ -547,7 +549,12 @@ export const useAuthStore = defineStore('auth', () => { await router.push({name: 'user.login'}) await checkAuth() - // if configured, redirect to OIDC Provider on logout + // Redirect to the OIDC provider to end its session too. Prefer the + // server-built RP-Initiated Logout URL, falling back to the static one. + 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) diff --git a/pkg/migration/20260619155410.go b/pkg/migration/20260619155410.go new file mode 100644 index 000000000..97f91bb5e --- /dev/null +++ b/pkg/migration/20260619155410.go @@ -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 . + +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 + }, + }) +} diff --git a/pkg/models/sessions.go b/pkg/models/sessions.go index 9c7a5d1f1..9824cf0c3 100644 --- a/pkg/models/sessions.go +++ b/pkg/models/sessions.go @@ -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 { diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index f94537158..87c89e5aa 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -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 } diff --git a/pkg/modules/auth/oauth2server/token.go b/pkg/modules/auth/oauth2server/token.go index 11f85772e..97978c0bf 100644 --- a/pkg/modules/auth/oauth2server/token.go +++ b/pkg/modules/auth/oauth2server/token.go @@ -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 diff --git a/pkg/modules/auth/openid/logout.go b/pkg/modules/auth/openid/logout.go new file mode 100644 index 000000000..958ea8765 --- /dev/null +++ b/pkg/modules/auth/openid/logout.go @@ -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 . + +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 +} diff --git a/pkg/modules/auth/openid/logout_test.go b/pkg/modules/auth/openid/logout_test.go new file mode 100644 index 000000000..57e3ea6a2 --- /dev/null +++ b/pkg/modules/auth/openid/logout_test.go @@ -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 . + +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()) +} diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 47ade7dc9..3b82379ac 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -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) { @@ -543,13 +553,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 +575,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 +602,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 } diff --git a/pkg/modules/auth/openid/providers.go b/pkg/modules/auth/openid/providers.go index 30f534ad9..710870b7d 100644 --- a/pkg/modules/auth/openid/providers.go +++ b/pkg/modules/auth/openid/providers.go @@ -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 } diff --git a/pkg/routes/api/shared/auth.go b/pkg/routes/api/shared/auth.go index 153d851e8..560ae0f47 100644 --- a/pkg/routes/api/shared/auth.go +++ b/pkg/routes/api/shared/auth.go @@ -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 diff --git a/pkg/routes/api/v1/login.go b/pkg/routes/api/v1/login.go index 2d740ffdf..6a4662ae2 100644 --- a/pkg/routes/api/v1/login.go +++ b/pkg/routes/api/v1/login.go @@ -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, + }) } diff --git a/pkg/routes/api/v2/auth_login.go b/pkg/routes/api/v2/auth_login.go index d6ff0ff19..519fcaef1 100644 --- a/pkg/routes/api/v2/auth_login.go +++ b/pkg/routes/api/v2/auth_login.go @@ -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 } diff --git a/pkg/routes/api/v2/auth_openid.go b/pkg/routes/api/v2/auth_openid.go index b52d7dca1..5e029a184 100644 --- a/pkg/routes/api/v2/auth_openid.go +++ b/pkg/routes/api/v2/auth_openid.go @@ -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) } diff --git a/pkg/webtests/huma_auth_login_test.go b/pkg/webtests/huma_auth_login_test.go index ad83fc811..48931effa 100644 --- a/pkg/webtests/huma_auth_login_test.go +++ b/pkg/webtests/huma_auth_login_test.go @@ -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()) From 764e4efa18b549cf4a0239c02863815b29b2ddfa Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Fri, 19 Jun 2026 16:52:13 +0000 Subject: [PATCH 09/40] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 16 ++++++++++++++-- pkg/swagger/swagger.json | 16 ++++++++++++++-- pkg/swagger/swagger.yaml | 13 ++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index fb3dfccdc..93de88c59 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -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" } } } @@ -11149,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": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 4fc8cfe9a..f02bf9bde 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.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" } } } @@ -11141,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": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index de0e5fbe2..775b2a024 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -1714,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: @@ -6945,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 From ab927aa772b97b7dde0c347195f78416ff25f0d0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:29:27 +0000 Subject: [PATCH 10/40] chore(deps): update dev-dependencies to v4.62.2 --- frontend/package.json | 2 +- frontend/pnpm-lock.yaml | 274 ++++++++++++++++++++-------------------- 2 files changed, 138 insertions(+), 138 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index f54d2ed79..3347deb8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -139,7 +139,7 @@ "postcss-easing-gradients": "3.0.1", "postcss-html": "1.8.1", "postcss-preset-env": "11.3.1", - "rollup": "4.62.1", + "rollup": "4.62.2", "rollup-plugin-visualizer": "6.0.11", "sass-embedded": "1.100.0", "stylelint": "17.13.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 997ec9709..b45f3e316 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: minimatch: ^10.2.3 - rollup: 4.62.1 + rollup: 4.62.2 basic-ftp: '>=5.2.2' serialize-javascript: ^7.0.5 flatted: ^3.4.1 @@ -41,7 +41,7 @@ importers: version: 3.1.3(@fortawesome/fontawesome-svg-core@7.1.0)(vue@3.5.27(typescript@5.9.3)) '@intlify/unplugin-vue-i18n': specifier: 11.0.3 - version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) + version: 11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.2)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) '@kyvg/vue3-notification': specifier: 3.4.2 version: 3.4.2(vue@3.5.27(typescript@5.9.3)) @@ -284,11 +284,11 @@ importers: specifier: 11.3.1 version: 11.3.1(postcss@8.5.14) rollup: - specifier: 4.62.1 - version: 4.62.1 + specifier: 4.62.2 + version: 4.62.2 rollup-plugin-visualizer: specifier: 6.0.11 - version: 6.0.11(rollup@4.62.1) + version: 6.0.11(rollup@4.62.2) sass-embedded: specifier: 1.100.0 version: 1.100.0 @@ -1940,7 +1940,7 @@ packages: peerDependencies: '@babel/core': '>=7.29.6' '@types/babel__core': ^7.1.9 - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: '@types/babel__core': optional: true @@ -1951,7 +1951,7 @@ packages: resolution: {integrity: sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -1960,7 +1960,7 @@ packages: resolution: {integrity: sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -1969,7 +1969,7 @@ packages: resolution: {integrity: sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==} engines: {node: '>=20.0.0'} peerDependencies: - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true @@ -1978,133 +1978,133 @@ packages: resolution: {integrity: sha512-Pnsb6f32CD2W3uCaLZIzDmeFyQ2b8UWMFI7xtwUezpcGBDVDW6y9XgAWIlARiGAo6eNF5FK5aQTr0LFyNyqq5A==} engines: {node: '>=14.0.0'} peerDependencies: - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: rollup: optional: true - '@rollup/rollup-android-arm-eabi@4.62.1': - resolution: {integrity: sha512-WUtumI+yIc7YXY3ZtN68V50CHEjgopo0rIZ90+ZqlZzIGroVn3qkfK7wkdl+HebaxenGQMrlB/KJs+aLMZg9lQ==} + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] os: [android] - '@rollup/rollup-android-arm64@4.62.1': - resolution: {integrity: sha512-ivTbxKROae184UB9SNQGOmXCwdgq1rb1OfDOXHOw9bHHVtoUSQoyLwAgxcd9zlef+vtPnyqN22HrYvaI7K12Zw==} + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} cpu: [arm64] os: [android] - '@rollup/rollup-darwin-arm64@4.62.1': - resolution: {integrity: sha512-+nRm4AIocYcaE5yP07KGybXGDGfBCXOSY7EE7GeGvA8rzK+eiZteAgn9VNkn8sw/+FWR+9FLyph0gUNuY75KuQ==} + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} cpu: [arm64] os: [darwin] - '@rollup/rollup-darwin-x64@4.62.1': - resolution: {integrity: sha512-63zVs6JwE9i3BMhHm1Gi5+LP8dRKQVrD5UzgjDgZfptON38vfStA4iAK0DpxqTmI8udUzr1Qwk1tEhLRcj7PVA==} + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} cpu: [x64] os: [darwin] - '@rollup/rollup-freebsd-arm64@4.62.1': - resolution: {integrity: sha512-uXASB7+/ZbR7q4RC35T/xTwQt4Qwt8e1my8E7hI6PxaQxuNiuvM+B/I58xvJLaVYOmCGy9cu3Ky1SSY4ia/G0Q==} + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} cpu: [arm64] os: [freebsd] - '@rollup/rollup-freebsd-x64@4.62.1': - resolution: {integrity: sha512-BqeibWSAOg/6bwxDnJ1Z4806jc6kIuGYCDS52DY4u23EgcK3DMrm4rrODmPTltA8EFlvhz2gXGhs/RwgWuto/w==} + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} cpu: [x64] os: [freebsd] - '@rollup/rollup-linux-arm-gnueabihf@4.62.1': - resolution: {integrity: sha512-9ryebRuEJ1OcKl9ZWWyXZ84OrpqXl8qwa99ZwrVn1uzBu9TwNqpyoScK7yF/+WoHW0dBGUR3tAHem7nWP1ismQ==} + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm-musleabihf@4.62.1': - resolution: {integrity: sha512-HOHv0qumBDTLxM/j5nE2X6SVHGK2F5r211WqFn0PB+lJL3o4HBP9CsjlcdwIk6aILYeRveltSVmvv9NSW3vnWg==} + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} cpu: [arm] os: [linux] - '@rollup/rollup-linux-arm64-gnu@4.62.1': - resolution: {integrity: sha512-jrDLxV5iWL8fdpj5N5+9ZAd2BjD3U6h1eiVhOCDQhvKG+C0uJt3phgIsS7sWKTk4LLaom87dMJCIXnakXEs4fA==} + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-arm64-musl@4.62.1': - resolution: {integrity: sha512-kaKe83aR+a5bvGTdXFlUzGUFPHoSm2zo1PFalUuwqj7+txbLm4jyXwM4IkmrEWK9yAWE9qO654XuBb8dqgSP4A==} + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} cpu: [arm64] os: [linux] - '@rollup/rollup-linux-loong64-gnu@4.62.1': - resolution: {integrity: sha512-tp+VgVhkZ9iNDGezXQnBx0h+ZraZJCKtbrsxGRSO3Y+Ta/YrUfLxlKXU4IiBm9AWlj9EDH1Djrvsl6ledeUdJg==} + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-loong64-musl@4.62.1': - resolution: {integrity: sha512-LN9invzRf8ejduiGlrtr46Gk08Uh/1eiMMLgo/CNPHeRpYH8EYW6YQuAqkoxItk+Rtmod1raQ8W49sO+hP+6hQ==} + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} cpu: [loong64] os: [linux] - '@rollup/rollup-linux-ppc64-gnu@4.62.1': - resolution: {integrity: sha512-F2Abce1ndQR8UXEX8Bj3EFd5jlw/u0rlbjmsEzBPty/YJ8H57x3POPnBxr7Mbi8m7UNwukwFW6Z20I+hrQvWdQ==} + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-ppc64-musl@4.62.1': - resolution: {integrity: sha512-nmJq25UletS/fI3icrKsBH8KDkTf7cSGTY5bkWI9z3+4oHj1DxHQkWCP8uP7m+AEhc1fc73AcycZam4iViAoNQ==} + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} cpu: [ppc64] os: [linux] - '@rollup/rollup-linux-riscv64-gnu@4.62.1': - resolution: {integrity: sha512-HqWXZHGXFrKmSs3qOmNBfLY34CzYDt3HU2oQq2cplmU1gEADa2dWf6xcjrQuHYbNYZpJY2+rLNAbHyXtrO/0PQ==} + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-riscv64-musl@4.62.1': - resolution: {integrity: sha512-xYDVRyJEbrzr14Z2hqe59C1pwosdl9Td0ik5gu5x85mVswTweg492as4Vzs/8zKkvvUgO5VdGRL7OzN+W9Z6+w==} + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} cpu: [riscv64] os: [linux] - '@rollup/rollup-linux-s390x-gnu@4.62.1': - resolution: {integrity: sha512-X6n4yZUYAGSZTsIRjHUFkRZy/ml+EyS5vsgnyUOfhflKros0TEjX9yAoFqiRdJSfmykStVUyfcFDy/tHJ64JuQ==} + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} cpu: [s390x] os: [linux] - '@rollup/rollup-linux-x64-gnu@4.62.1': - resolution: {integrity: sha512-nVQGk/jStQc2V4rrkI+vPD2J+85boKqS4R4nOdPhc3eWw0kyW/b+AYRGoH8qo057XSVqaTx13AliH5qPeLTtgQ==} + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} cpu: [x64] os: [linux] - '@rollup/rollup-linux-x64-musl@4.62.1': - resolution: {integrity: sha512-Ae54IyMwpY3JsYjBH4k29vQ9FSoILwJdh7j7c9lmLOczKnU/WL5jMRL9epsgPrs+ph48YVTsy6PkQDq0nK8Kvg==} + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} cpu: [x64] os: [linux] - '@rollup/rollup-openbsd-x64@4.62.1': - resolution: {integrity: sha512-7oOS0UqUXLRi2dVeEXdQxbml854xxQSx+6Pdnuo4G0iAIRiPBCIyzhLIv8oSmvqLkAftGaRk+ft70fVHXjsXsQ==} + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} cpu: [x64] os: [openbsd] - '@rollup/rollup-openharmony-arm64@4.62.1': - resolution: {integrity: sha512-e6kAhhmUK3pwICnBtsQFkg/czVxFlY5e4Ppi4fuXWvOwiHOXlgQMEvpg0H5ceuEh2T1nyI0U6SfhV3qojKWpAg==} + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} cpu: [arm64] os: [openharmony] - '@rollup/rollup-win32-arm64-msvc@4.62.1': - resolution: {integrity: sha512-xXRJSv00uVmj5DwS9DwIvS+Re5VdDnaspDfk7GzsnhP1IbTzFjJwhY+c3j3jr/2pP/prBrXvZ1OmjjhkkAOUlQ==} + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} cpu: [arm64] os: [win32] - '@rollup/rollup-win32-ia32-msvc@4.62.1': - resolution: {integrity: sha512-D3S8+6cSEW0QZZHcKKDQ/Fsz/eqvYmJbtkZZziFxEb4Fi4fyWTCaMs1p5siQ85/T6gNdYKJ3OIJ4M/phYQgICA==} + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} cpu: [ia32] os: [win32] - '@rollup/rollup-win32-x64-gnu@4.62.1': - resolution: {integrity: sha512-CRVGPQKdEB/ujGfrq3SgITWc2N9iWM+sqaBKHh62Dc6xRLQGTVrqHpOVEitfly941kr244j14sswRw47bmMjjg==} + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} cpu: [x64] os: [win32] - '@rollup/rollup-win32-x64-msvc@4.62.1': - resolution: {integrity: sha512-/N8QHE1y6A9nmN3HCIFZWr5FUu/rKcT/A7JgaMJH3dcvL5RS++o0brK5SitYVTis/dJFiasK7Xva0cqeWYmCzQ==} + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} cpu: [x64] os: [win32] @@ -5562,15 +5562,15 @@ packages: hasBin: true peerDependencies: rolldown: 1.x || ^1.0.0-beta - rollup: 4.62.1 + rollup: 4.62.2 peerDependenciesMeta: rolldown: optional: true rollup: optional: true - rollup@4.62.1: - resolution: {integrity: sha512-XTvxjHHM/0J/WZBg+ehDbAZgIpZoIZtWO+aImyuhjoyQa56NBX/bqnXw32rT27fkjSRrqthOgkLjRVtwXFI7jQ==} + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -8353,13 +8353,13 @@ snapshots: '@intlify/shared@11.2.8': {} - '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.1)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': + '@intlify/unplugin-vue-i18n@11.0.3(@vue/compiler-dom@3.5.27)(eslint@9.39.4(jiti@2.6.1))(rollup@4.62.2)(typescript@5.9.3)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) '@intlify/bundle-utils': 11.0.3(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3))) '@intlify/shared': 11.2.8 '@intlify/vue-i18n-extensions': 8.0.0(@intlify/shared@11.2.8)(@vue/compiler-dom@3.5.27)(vue-i18n@11.2.8(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3)) - '@rollup/pluginutils': 5.1.3(rollup@4.62.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) '@typescript-eslint/scope-manager': 8.58.0 '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) debug: 4.4.3 @@ -8603,122 +8603,122 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.1)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7)(rollup@4.62.2)': dependencies: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.25.9 - '@rollup/pluginutils': 5.1.3(rollup@4.62.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 transitivePeerDependencies: - supports-color - '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.1)': + '@rollup/plugin-node-resolve@16.0.3(rollup@4.62.2)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.62.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) '@types/resolve': 1.20.2 deepmerge: 4.3.1 is-module: 1.0.0 resolve: 1.22.8 optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 - '@rollup/plugin-replace@6.0.3(rollup@4.62.1)': + '@rollup/plugin-replace@6.0.3(rollup@4.62.2)': dependencies: - '@rollup/pluginutils': 5.1.3(rollup@4.62.1) + '@rollup/pluginutils': 5.1.3(rollup@4.62.2) magic-string: 0.30.21 optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 - '@rollup/plugin-terser@1.0.0(rollup@4.62.1)': + '@rollup/plugin-terser@1.0.0(rollup@4.62.2)': dependencies: serialize-javascript: 7.0.5 smob: 1.5.0 terser: 5.31.6 optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 - '@rollup/pluginutils@5.1.3(rollup@4.62.1)': + '@rollup/pluginutils@5.1.3(rollup@4.62.2)': dependencies: '@types/estree': 1.0.9 estree-walker: 2.0.2 picomatch: 4.0.4 optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 - '@rollup/rollup-android-arm-eabi@4.62.1': + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true - '@rollup/rollup-android-arm64@4.62.1': + '@rollup/rollup-android-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-arm64@4.62.1': + '@rollup/rollup-darwin-arm64@4.62.2': optional: true - '@rollup/rollup-darwin-x64@4.62.1': + '@rollup/rollup-darwin-x64@4.62.2': optional: true - '@rollup/rollup-freebsd-arm64@4.62.1': + '@rollup/rollup-freebsd-arm64@4.62.2': optional: true - '@rollup/rollup-freebsd-x64@4.62.1': + '@rollup/rollup-freebsd-x64@4.62.2': optional: true - '@rollup/rollup-linux-arm-gnueabihf@4.62.1': + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm-musleabihf@4.62.1': + '@rollup/rollup-linux-arm-musleabihf@4.62.2': optional: true - '@rollup/rollup-linux-arm64-gnu@4.62.1': + '@rollup/rollup-linux-arm64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-arm64-musl@4.62.1': + '@rollup/rollup-linux-arm64-musl@4.62.2': optional: true - '@rollup/rollup-linux-loong64-gnu@4.62.1': + '@rollup/rollup-linux-loong64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-loong64-musl@4.62.1': + '@rollup/rollup-linux-loong64-musl@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-gnu@4.62.1': + '@rollup/rollup-linux-ppc64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-ppc64-musl@4.62.1': + '@rollup/rollup-linux-ppc64-musl@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-gnu@4.62.1': + '@rollup/rollup-linux-riscv64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-riscv64-musl@4.62.1': + '@rollup/rollup-linux-riscv64-musl@4.62.2': optional: true - '@rollup/rollup-linux-s390x-gnu@4.62.1': + '@rollup/rollup-linux-s390x-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-gnu@4.62.1': + '@rollup/rollup-linux-x64-gnu@4.62.2': optional: true - '@rollup/rollup-linux-x64-musl@4.62.1': + '@rollup/rollup-linux-x64-musl@4.62.2': optional: true - '@rollup/rollup-openbsd-x64@4.62.1': + '@rollup/rollup-openbsd-x64@4.62.2': optional: true - '@rollup/rollup-openharmony-arm64@4.62.1': + '@rollup/rollup-openharmony-arm64@4.62.2': optional: true - '@rollup/rollup-win32-arm64-msvc@4.62.1': + '@rollup/rollup-win32-arm64-msvc@4.62.2': optional: true - '@rollup/rollup-win32-ia32-msvc@4.62.1': + '@rollup/rollup-win32-ia32-msvc@4.62.2': optional: true - '@rollup/rollup-win32-x64-gnu@4.62.1': + '@rollup/rollup-win32-x64-gnu@4.62.2': optional: true - '@rollup/rollup-win32-x64-msvc@4.62.1': + '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true '@sentry-internal/browser-utils@10.36.0': @@ -12540,44 +12540,44 @@ snapshots: rfdc@1.4.1: {} - rollup-plugin-visualizer@6.0.11(rollup@4.62.1): + rollup-plugin-visualizer@6.0.11(rollup@4.62.2): dependencies: open: 8.4.2 picomatch: 4.0.4 source-map: 0.7.4 yargs: 17.7.2 optionalDependencies: - rollup: 4.62.1 + rollup: 4.62.2 - rollup@4.62.1: + rollup@4.62.2: dependencies: '@types/estree': 1.0.9 optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.62.1 - '@rollup/rollup-android-arm64': 4.62.1 - '@rollup/rollup-darwin-arm64': 4.62.1 - '@rollup/rollup-darwin-x64': 4.62.1 - '@rollup/rollup-freebsd-arm64': 4.62.1 - '@rollup/rollup-freebsd-x64': 4.62.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.62.1 - '@rollup/rollup-linux-arm-musleabihf': 4.62.1 - '@rollup/rollup-linux-arm64-gnu': 4.62.1 - '@rollup/rollup-linux-arm64-musl': 4.62.1 - '@rollup/rollup-linux-loong64-gnu': 4.62.1 - '@rollup/rollup-linux-loong64-musl': 4.62.1 - '@rollup/rollup-linux-ppc64-gnu': 4.62.1 - '@rollup/rollup-linux-ppc64-musl': 4.62.1 - '@rollup/rollup-linux-riscv64-gnu': 4.62.1 - '@rollup/rollup-linux-riscv64-musl': 4.62.1 - '@rollup/rollup-linux-s390x-gnu': 4.62.1 - '@rollup/rollup-linux-x64-gnu': 4.62.1 - '@rollup/rollup-linux-x64-musl': 4.62.1 - '@rollup/rollup-openbsd-x64': 4.62.1 - '@rollup/rollup-openharmony-arm64': 4.62.1 - '@rollup/rollup-win32-arm64-msvc': 4.62.1 - '@rollup/rollup-win32-ia32-msvc': 4.62.1 - '@rollup/rollup-win32-x64-gnu': 4.62.1 - '@rollup/rollup-win32-x64-msvc': 4.62.1 + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 fsevents: 2.3.3 rope-sequence@1.3.4: {} @@ -13580,7 +13580,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 postcss: 8.5.14 - rollup: 4.62.1 + rollup: 4.62.2 tinyglobby: 0.2.15 optionalDependencies: '@types/node': 24.13.2 @@ -13820,10 +13820,10 @@ snapshots: '@babel/core': 7.29.7 '@babel/preset-env': 7.26.0(@babel/core@7.29.7) '@babel/runtime': 7.25.4 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.1) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.1) - '@rollup/plugin-replace': 6.0.3(rollup@4.62.1) - '@rollup/plugin-terser': 1.0.0(rollup@4.62.1) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7)(rollup@4.62.2) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.62.2) + '@rollup/plugin-replace': 6.0.3(rollup@4.62.2) + '@rollup/plugin-terser': 1.0.0(rollup@4.62.2) '@trickfilm400/rollup-plugin-off-main-thread': 3.0.0-pre1 ajv: 8.18.0 common-tags: 1.8.2 @@ -13832,7 +13832,7 @@ snapshots: fs-extra: 9.1.0 glob: 11.1.0 pretty-bytes: 5.6.0 - rollup: 4.62.1 + rollup: 4.62.2 source-map: 0.8.0-beta.0 stringify-object: 3.3.0 strip-comments: 2.0.1 From b6af13284598e2f0a977846267d64cfc23ba7022 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 19:50:47 +0200 Subject: [PATCH 11/40] fix(auth): preserve desktop authorize URL when not signed in (#2944) --- frontend/src/constants/redirectHash.ts | 12 +++ frontend/src/router/index.ts | 58 +++++++++++-- .../tests/e2e/user/oauth-authorize.spec.ts | 82 ++++++++++++++++++- 3 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 frontend/src/constants/redirectHash.ts diff --git a/frontend/src/constants/redirectHash.ts b/frontend/src/constants/redirectHash.ts new file mode 100644 index 000000000..1dd648e58 --- /dev/null +++ b/frontend/src/constants/redirectHash.ts @@ -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=' diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index cc693cc48..02e5ad580 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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= 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 } diff --git a/frontend/tests/e2e/user/oauth-authorize.spec.ts b/frontend/tests/e2e/user/oauth-authorize.spec.ts index 908e9ae3f..572802e46 100644 --- a/frontend/tests/e2e/user/oauth-authorize.spec.ts +++ b/frontend/tests/e2e/user/oauth-authorize.spec.ts @@ -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= 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) + }) }) From 81791fd34693641da63e3fc158b0915fc9f3f635 Mon Sep 17 00:00:00 2001 From: Tink Date: Fri, 19 Jun 2026 20:47:05 +0200 Subject: [PATCH 12/40] fix(auth): link OIDC username fallback on preferred_username, not just sub (#2945) --- pkg/modules/auth/openid/openid.go | 80 ++++++++++++++++-------- pkg/modules/auth/openid/openid_test.go | 86 ++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 26 deletions(-) diff --git a/pkg/modules/auth/openid/openid.go b/pkg/modules/auth/openid/openid.go index 3b82379ac..3fd7a6cfa 100644 --- a/pkg/modules/auth/openid/openid.go +++ b/pkg/modules/auth/openid/openid.go @@ -377,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 @@ -402,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 + } } } diff --git a/pkg/modules/auth/openid/openid_test.go b/pkg/modules/auth/openid/openid_test.go index 05ade6745..35bb27547 100644 --- a/pkg/modules/auth/openid/openid_test.go +++ b/pkg/modules/auth/openid/openid_test.go @@ -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) { From 63b7f32379a6f438a93c66fd19400b7ad98ace4b Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 19 Jun 2026 20:38:43 +0200 Subject: [PATCH 13/40] fix(editor): render floating popups inside the task dialog (Kanban popup) The Kanban task detail opens as a native via showModal(), which paints in the browser top-layer. Floating UI appended to document.body (or teleported to ) then renders behind the dialog regardless of z-index, matching the bug class of #2940 / #1746 / #1899 / #1929. - Emoji autocomplete popup: append to getPopupContainer(editor) (the open dialog ancestor, else body), the same helper the slash menu and mentions already use. Also switch its unmount to popupElement.remove() so it works no matter which container it was appended to. - Attachment dropzone overlay: teleport into the topmost open dialog.modal-dialog instead of always , mirroring Notification.vue, so the drag-and-drop hint is visible while a task detail dialog is open. --- .../input/editor/emoji/emojiSuggestion.ts | 5 +-- .../components/tasks/partials/Attachments.vue | 34 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/input/editor/emoji/emojiSuggestion.ts b/frontend/src/components/input/editor/emoji/emojiSuggestion.ts index 22dad82b2..b9fb7c961 100644 --- a/frontend/src/components/input/editor/emoji/emojiSuggestion.ts +++ b/frontend/src/components/input/editor/emoji/emojiSuggestion.ts @@ -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() diff --git a/frontend/src/components/tasks/partials/Attachments.vue b/frontend/src/components/tasks/partials/Attachments.vue index 96e965153..322fddaa3 100644 --- a/frontend/src/components/tasks/partials/Attachments.vue +++ b/frontend/src/components/tasks/partials/Attachments.vue @@ -123,7 +123,7 @@ - +