Skip to content

Commit ecdfcd4

Browse files
committed
feat(frontend): add planka migration form
URL + API key or username/password, talking to the v2 migration api (all code migrators now use v2). Generic credentials form component with planka-specific help texts, status screens for running/previous migrations, translated error codes.
1 parent 8477f5b commit ecdfcd4

7 files changed

Lines changed: 277 additions & 22 deletions

File tree

frontend/src/components/input/FormField.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import {computed, useSlots, useId, ref} from 'vue'
2+
import {computed, useSlots, useId, ref, useAttrs} from 'vue'
33
44
interface Props {
55
modelValue?: string | number
@@ -36,6 +36,8 @@ const generatedId = useId()
3636
3737
const inputId = computed(() => props.id ?? generatedId)
3838
const errorId = computed(() => props.error ? `${inputId.value}-error` : undefined)
39+
const attrs = useAttrs()
40+
const describedBy = computed(() => [attrs['aria-describedby'], errorId.value].filter(Boolean).join(' ') || undefined)
3941
const hasAddon = computed(() => !!slots.addon)
4042
4143
const fieldClasses = computed(() => [
@@ -94,7 +96,7 @@ defineExpose({
9496
:class="inputClasses"
9597
:disabled="disabled || undefined"
9698
:aria-invalid="error ? true : undefined"
97-
:aria-describedby="errorId"
99+
:aria-describedby="describedBy"
98100
@input="handleInput"
99101
>
100102
</slot>
@@ -126,7 +128,7 @@ defineExpose({
126128
:class="inputClasses"
127129
:disabled="disabled || undefined"
128130
:aria-invalid="error ? true : undefined"
129-
:aria-describedby="errorId"
131+
:aria-describedby="describedBy"
130132
@input="handleInput"
131133
>
132134
</slot>

frontend/src/i18n/lang/en.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -698,6 +698,19 @@
698698
"upload": "Upload file",
699699
"migrationStartedWillReciveEmail": "Vikunja will now import your lists/projects, tasks, notes, reminders and files from {service}. As this will take a while, we will send you an email once done. You can close this window now.",
700700
"migrationInProgress": "A migration is currently in progress. Please wait until it is done.",
701+
"credentials": {
702+
"description": "Enter the address of your {name} instance and how Vikunja should log in. Vikunja imports all projects and boards you have access to; the credentials are only used for this import and are not stored.",
703+
"url": "{name} URL",
704+
"authMethod": "Log in with",
705+
"apiKey": "API key",
706+
"apiKeyRequired": "Please enter an API key.",
707+
"authPassword": "Username and password",
708+
"start": "Start import"
709+
},
710+
"planka": {
711+
"apiKeyHelp": "An API key must be created for your account by a Planka administrator. Copying the token from the browser does not work.",
712+
"passwordHelp": "Only works for local Planka accounts without two-factor authentication that have logged in to Planka at least once. Accounts using single sign-on need an API key."
713+
},
701714
"csv": {
702715
"description": "Import tasks from a CSV file with custom column mapping.",
703716
"uploadDescription": "Select a CSV file to import. The file should contain task data with headers in the first row.",
@@ -1505,7 +1518,12 @@
15051518
"13002": "The provided link share password is invalid.",
15061519
"13003": "The provided link share token is invalid.",
15071520
"14001": "The provided api token is invalid.",
1508-
"14002": "The permission {permission} of group {group} is invalid."
1521+
"14002": "The permission {permission} of group {group} is invalid.",
1522+
"14005": "A migration from this service is already running. Please wait until it is done.",
1523+
"14201": "Planka rejected the provided credentials. Check the URL and the API key or username and password.",
1524+
"14202": "The Planka account requires an extra login step (two-factor code or accepting the terms). Log in to Planka in the browser once, or use an API key instead of username and password.",
1525+
"14203": "A Planka URL and either an API key or username and password are required.",
1526+
"14204": "Could not reach a Planka API at the given URL. Check the URL and that Vikunja can reach the instance."
15091527
},
15101528
"about": {
15111529
"title": "About",

frontend/src/services/migrator/abstractMigration.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import AbstractService from '../abstractService'
2+
import {apiV2Url} from '@/helpers/fetcher'
23

3-
export type MigrationConfig = { code: string }
4+
export type MigrationConfig =
5+
| { code: string }
6+
| { url: string, token: string }
7+
| { url: string, username: string, password: string }
48

59
// This service builds on top of the abstract service and basically just hides away method names.
610
// It enables migration services to be created with minimal overhead and even better method names.
@@ -9,17 +13,17 @@ export default class AbstractMigrationService extends AbstractService<MigrationC
913

1014
constructor(serviceUrlKey: string) {
1115
super({
12-
update: '/migration/' + serviceUrlKey + '/migrate',
16+
update: apiV2Url(`migration/${serviceUrlKey}/migrate`),
1317
})
1418
this.serviceUrlKey = serviceUrlKey
1519
}
1620

1721
getAuthUrl() {
18-
return this.getM('/migration/' + this.serviceUrlKey + '/auth')
22+
return this.getM(apiV2Url(`migration/${this.serviceUrlKey}/auth`))
1923
}
2024

2125
getStatus() {
22-
return this.getM('/migration/' + this.serviceUrlKey + '/status')
26+
return this.getM(apiV2Url(`migration/${this.serviceUrlKey}/status`))
2327
}
2428

2529
migrate(data: MigrationConfig) {
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
<template>
2+
<form
3+
class="credentials-form"
4+
novalidate
5+
@submit.prevent="submit"
6+
>
7+
<p>{{ $t('migrate.credentials.description', {name: migratorName}) }}</p>
8+
<Message
9+
v-if="error"
10+
variant="danger"
11+
class="mbe-4"
12+
>
13+
{{ error }}
14+
</Message>
15+
<FormField
16+
id="migration-url"
17+
v-model="credentials.url"
18+
v-focus
19+
:label="$t('migrate.credentials.url', {name: migratorName})"
20+
:placeholder="urlPlaceholder"
21+
:error="credentialsErrors.url"
22+
type="url"
23+
autocomplete="url"
24+
/>
25+
<fieldset class="field">
26+
<legend class="label">
27+
{{ $t('migrate.credentials.authMethod') }}
28+
</legend>
29+
<div class="control auth-method">
30+
<label class="radio">
31+
<input
32+
v-model="authMethod"
33+
type="radio"
34+
name="auth-method"
35+
value="token"
36+
>
37+
{{ $t('migrate.credentials.apiKey') }}
38+
</label>
39+
<label class="radio">
40+
<input
41+
v-model="authMethod"
42+
type="radio"
43+
name="auth-method"
44+
value="password"
45+
>
46+
{{ $t('migrate.credentials.authPassword') }}
47+
</label>
48+
</div>
49+
</fieldset>
50+
<template v-if="authMethod === 'token'">
51+
<FormField
52+
id="migration-token"
53+
v-model="credentials.token"
54+
:label="$t('migrate.credentials.apiKey')"
55+
:error="credentialsErrors.token"
56+
type="password"
57+
autocomplete="off"
58+
:aria-describedby="apiKeyHelp ? 'migration-token-help' : undefined"
59+
/>
60+
<p
61+
v-if="apiKeyHelp"
62+
id="migration-token-help"
63+
class="help"
64+
>
65+
{{ apiKeyHelp }}
66+
</p>
67+
</template>
68+
<template v-else>
69+
<FormField
70+
id="migration-username"
71+
v-model="credentials.username"
72+
:label="$t('user.auth.usernameEmail')"
73+
:error="credentialsErrors.username"
74+
type="text"
75+
autocomplete="off"
76+
/>
77+
<FormField
78+
id="migration-password"
79+
v-model="credentials.password"
80+
:label="$t('user.auth.password')"
81+
:error="credentialsErrors.password"
82+
type="password"
83+
autocomplete="off"
84+
:aria-describedby="passwordHelp ? 'migration-password-help' : undefined"
85+
/>
86+
<p
87+
v-if="passwordHelp"
88+
id="migration-password-help"
89+
class="help"
90+
>
91+
{{ passwordHelp }}
92+
</p>
93+
</template>
94+
<XButton
95+
type="submit"
96+
:loading="loading"
97+
:disabled="loading || undefined"
98+
>
99+
{{ $t('migrate.credentials.start') }}
100+
</XButton>
101+
</form>
102+
</template>
103+
104+
<script setup lang="ts">
105+
import {computed, reactive, ref} from 'vue'
106+
import {useI18n} from 'vue-i18n'
107+
108+
import FormField from '@/components/input/FormField.vue'
109+
import Message from '@/components/misc/Message.vue'
110+
111+
import type {MigrationConfig} from '@/services/migrator/abstractMigration'
112+
113+
const props = withDefaults(defineProps<{
114+
migratorName: string,
115+
loading: boolean,
116+
error: string,
117+
apiKeyHelp?: string,
118+
passwordHelp?: string,
119+
}>(), {
120+
apiKeyHelp: '',
121+
passwordHelp: '',
122+
})
123+
124+
const emit = defineEmits<{
125+
submit: [config: MigrationConfig],
126+
clearError: [],
127+
}>()
128+
129+
const {t} = useI18n({useScope: 'global'})
130+
131+
const urlPlaceholder = computed(() => `https://${props.migratorName.toLowerCase()}.example.com`)
132+
133+
const authMethod = ref<'token' | 'password'>('token')
134+
const credentials = reactive({url: '', token: '', username: '', password: ''})
135+
const credentialsErrors = reactive<{
136+
url: string | null,
137+
token: string | null,
138+
username: string | null,
139+
password: string | null,
140+
}>({url: null, token: null, username: null, password: null})
141+
142+
function submit() {
143+
emit('clearError')
144+
145+
const url = credentials.url.trim()
146+
credentialsErrors.url = url === '' ? t('apiConfig.urlRequired') : null
147+
148+
if (authMethod.value === 'token') {
149+
credentialsErrors.token = credentials.token.trim() === '' ? t('migrate.credentials.apiKeyRequired') : null
150+
credentialsErrors.username = null
151+
credentialsErrors.password = null
152+
} else {
153+
credentialsErrors.token = null
154+
credentialsErrors.username = credentials.username.trim() === '' ? t('user.auth.usernameRequired') : null
155+
credentialsErrors.password = credentials.password === '' ? t('user.auth.passwordRequired') : null
156+
}
157+
158+
if (credentialsErrors.url || credentialsErrors.token || credentialsErrors.username || credentialsErrors.password) {
159+
return
160+
}
161+
162+
emit('submit', authMethod.value === 'token'
163+
? {url, token: credentials.token.trim()}
164+
: {url, username: credentials.username.trim(), password: credentials.password})
165+
}
166+
</script>
167+
168+
<style lang="scss" scoped>
169+
.credentials-form {
170+
max-inline-size: 500px;
171+
}
172+
173+
fieldset {
174+
border: 0;
175+
margin: 0;
176+
padding: 0;
177+
}
178+
179+
.auth-method {
180+
display: flex;
181+
flex-wrap: wrap;
182+
gap: 1rem;
183+
}
184+
</style>

0 commit comments

Comments
 (0)