mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-04-24 22:25:15 +00:00
feat: show user export status in settings (#1200)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,4 +43,3 @@ devenv.local.nix
|
||||
# AI Tools
|
||||
/.claude/
|
||||
PLAN.md
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -155,6 +155,7 @@ func TestProjectUser_ReadAll(t *testing.T) {
|
||||
OverdueTasksRemindersTime: "09:00",
|
||||
Created: testCreatedTime,
|
||||
Updated: testUpdatedTime,
|
||||
ExportFileID: 1,
|
||||
},
|
||||
Right: RightRead,
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func TestTaskCollection_ReadAll(t *testing.T) {
|
||||
OverdueTasksRemindersTime: "09:00",
|
||||
Created: testCreatedTime,
|
||||
Updated: testUpdatedTime,
|
||||
ExportFileID: 1,
|
||||
}
|
||||
user2 := &user.User{
|
||||
ID: 2,
|
||||
|
||||
@@ -35,6 +35,7 @@ func TestListUsersFromProject(t *testing.T) {
|
||||
OverdueTasksRemindersTime: "09:00",
|
||||
Created: testCreatedTime,
|
||||
Updated: testUpdatedTime,
|
||||
ExportFileID: 1,
|
||||
}
|
||||
testuser2 := &user.User{
|
||||
ID: 2,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
41
pkg/webtests/user_export_status_test.go
Normal file
41
pkg/webtests/user_export_status_test.go
Normal 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`)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user