feat: show user export status in settings (#1200)

This commit is contained in:
kolaente
2025-07-30 17:50:26 +02:00
committed by GitHub
parent c303344183
commit 4042f66efa
13 changed files with 181 additions and 5 deletions

1
.gitignore vendored
View File

@@ -43,4 +43,3 @@ devenv.local.nix
# AI Tools
/.claude/
PLAN.md

View File

@@ -219,7 +219,9 @@
"descriptionPasswordRequired": "Please enter your password to proceed:",
"request": "Request a copy of my Vikunja Data",
"success": "You've successfully requested your Vikunja Data! We will send you an email once it's ready to download.",
"downloadTitle": "Download your exported Vikunja data"
"downloadTitle": "Download your exported Vikunja data",
"ready": "Your export is ready to download. You can download it until {0}.",
"requestNew": "Request another export"
}
},
"project": {

View File

@@ -7,6 +7,10 @@ export default class DataExportService extends AbstractService {
request(password: string) {
return this.post('/user/export/request', {password})
}
status() {
return this.getM('/user/export')
}
async download(password: string) {
const clear = this.setLoading()

View File

@@ -34,11 +34,18 @@
<XButton
v-focus
:loading="dataExportService.loading"
class="mt-4"
class="mt-4 mr-4"
@click="download()"
>
{{ $t('misc.download') }}
</XButton>
<XButton
class="button mt-4"
:to="{name:'user.settings.data-export'}"
variant="tertary"
>
{{ $t('user.export.requestNew') }}
</XButton>
</div>
</template>

View File

@@ -1,5 +1,31 @@
<template>
<Card :title="$t('user.export.title')">
<Message
v-if="exportInfo"
class="mb-4"
>
<div class="export-message">
<p>
<i18n-t
keypath="user.export.ready"
scope="global"
>
<time
v-tooltip="formatDateLong(exportInfo.expires)"
:datetime="formatISO(exportInfo.expires)"
>
{{ formattedExpiresDate }}
</time>
</i18n-t>
</p>
<XButton
:to="{name:'user.export.download'}"
class="button"
>
{{ $t('misc.download') }}
</XButton>
</div>
</Message>
<p>
{{ $t('user.export.description') }}
</p>
@@ -45,15 +71,17 @@
</Card>
</template>
<script setup lang="ts">
import {ref, computed, shallowReactive} from 'vue'
import {ref, computed, shallowReactive, onMounted} from 'vue'
import {useI18n} from 'vue-i18n'
import DataExportService from '@/services/dataExport'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import {formatISO, formatDateLong, formatDisplayDate} from '@/helpers/time/formatDate'
import Message from '@/components/misc/Message.vue'
defineOptions({name: 'UserSettingsDataExport'})
@@ -63,11 +91,29 @@ const authStore = useAuthStore()
useTitle(() => `${t('user.export.title')} - ${t('user.settings.title')}`)
const dataExportService = shallowReactive(new DataExportService())
interface ExportInfo {
id: number
size: number
created: string
expires: string
}
const exportInfo = ref<ExportInfo | null>(null)
const password = ref('')
const errPasswordRequired = ref(false)
const isLocalUser = computed(() => authStore.info?.isLocalUser)
const passwordInput = ref()
const formattedExpiresDate = computed(() => exportInfo.value ? formatDisplayDate(new Date(exportInfo.value.expires)) : '')
onMounted(async () => {
try {
const data = await dataExportService.status()
exportInfo.value = data
} catch {
exportInfo.value = null
}
})
async function requestDataExport() {
if (password.value === '' && isLocalUser.value) {
errPasswordRequired.value = true
@@ -80,3 +126,30 @@ async function requestDataExport() {
password.value = ''
}
</script>
<style lang="scss" scoped>
.export-message {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
gap: .5rem;
> p {
margin-bottom: 0;
}
@media (max-width: $mobile) {
flex-direction: column;
align-items: flex-start;
> p {
margin-bottom: 1rem;
}
> :deep(.button) {
width: 100%;
}
}
}
</style>

View File

@@ -6,6 +6,7 @@
issuer: local
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
export_file_id: 1
-
id: 2
username: 'user2'

View File

@@ -57,6 +57,7 @@ func TestLabel_ReadAll(t *testing.T) {
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
user2 := &user.User{
ID: 2,
@@ -188,6 +189,7 @@ func TestLabel_ReadOne(t *testing.T) {
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
tests := []struct {
name string

View File

@@ -155,6 +155,7 @@ func TestProjectUser_ReadAll(t *testing.T) {
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
},
Right: RightRead,
}

View File

@@ -46,6 +46,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
user2 := &user.User{
ID: 2,

View File

@@ -35,6 +35,7 @@ func TestListUsersFromProject(t *testing.T) {
OverdueTasksRemindersTime: "09:00",
Created: testCreatedTime,
Updated: testUpdatedTime,
ExportFileID: 1,
}
testuser2 := &user.User{
ID: 2,

View File

@@ -18,6 +18,7 @@ package v1
import (
"net/http"
"time"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/events"
@@ -139,3 +140,45 @@ func DownloadUserDataExport(c echo.Context) error {
http.ServeContent(c.Response(), c.Request(), exportFile.Name, exportFile.Created, exportFile.File)
return nil
}
type UserExportStatus struct {
ID int64 `json:"id"`
Size uint64 `json:"size"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
}
// GetUserExportStatus returns metadata about the current user export if it exists
// @Summary Get current user data export
// @tags user
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} v1.UserExportStatus
// @Router /user/export [get]
func GetUserExportStatus(c echo.Context) error {
s := db.NewSession()
defer s.Close()
u, err := user.GetCurrentUserFromDB(s, c)
if err != nil {
return handler.HandleHTTPError(err)
}
if u.ExportFileID == 0 {
return c.JSON(http.StatusOK, struct{}{})
}
exportFile := &files.File{ID: u.ExportFileID}
if err := exportFile.LoadFileMetaByID(); err != nil {
return handler.HandleHTTPError(err)
}
status := UserExportStatus{
ID: exportFile.ID,
Size: exportFile.Size,
Created: exportFile.Created,
Expires: exportFile.Created.Add(7 * 24 * time.Hour),
}
return c.JSON(http.StatusOK, status)
}

View File

@@ -322,6 +322,7 @@ func registerAPIRoutes(a *echo.Group) {
u.POST("/settings/general", apiv1.UpdateGeneralUserSettings)
u.POST("/export/request", apiv1.RequestUserDataExport)
u.POST("/export/download", apiv1.DownloadUserDataExport)
u.GET("/export", apiv1.GetUserExportStatus)
u.GET("/timezones", apiv1.GetAvailableTimezones)
u.PUT("/settings/token/caldav", apiv1.GenerateCaldavToken)
u.GET("/settings/token/caldav", apiv1.GetCaldavTokens)

View File

@@ -0,0 +1,41 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package webtests
import (
"net/http"
"testing"
apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserExportStatus(t *testing.T) {
t.Run("no export", func(t *testing.T) {
rec, err := newTestRequestWithUser(t, http.MethodGet, apiv1.GetUserExportStatus, &testuser15, "", nil, nil)
require.NoError(t, err)
assert.Equal(t, "{}\n", rec.Body.String())
})
t.Run("with export", func(t *testing.T) {
rec, err := newTestRequestWithUser(t, http.MethodGet, apiv1.GetUserExportStatus, &testuser1, "", nil, nil)
require.NoError(t, err)
assert.Contains(t, rec.Body.String(), `"id":1`)
})
}