feat: manage caldav tokens [addition] #1307

Merged
konrad merged 24 commits from dpschen/frontend:feature/caldav-tokens into main 2022-04-02 15:51:44 +00:00
5 changed files with 138 additions and 38 deletions

View File

@ -103,9 +103,16 @@
"disableSuccess": "Two factor authentication was sucessfully disabled."
},
"caldav": {
dpschen marked this conversation as resolved
Review

Is it possible to change this translation id?

Is it possible to change this translation id?
Review

Can you make sure every string is shown correctly after changing it? Maybe not in this PR?

Can you make sure every string is shown correctly after changing it? Maybe not in this PR?
"title": "Caldav",
"howTo": "You can connect Vikunja to caldav clients to view and manage all tasks from different clients. Enter this url into your client:",
"more": "More information about caldav in Vikunja"
"title": "CalDAV",
"howTo": "You can connect Vikunja to CalDAV clients to view and manage all tasks from different clients. Enter this url into your client:",
"more": "More information about CalDAV in Vikunja",
"tokens": "CalDAV Tokens",
"tokensHowTo": "You can use a CalDAV token to use instead of a password to log in the above endpoint.",
"createToken": "Create a token",
"tokenCreated": "Here is your token: {token}",
"wontSeeItAgain": "Write it down, you won't be able to see it again.",
"mustUseToken": "You need to create a CalDAV token if you want to use CalDAV with a third party client. Use the token as the password.",
"usernameIs": "Your username is: {0}"
},
"avatar": {
"title": "Avatar",
@ -486,7 +493,10 @@
"hideMenu": "Hide the menu",
"forExample": "For example:",
"welcomeBack": "Welcome Back!",
"custom": "Custom"
"custom": "Custom",
"id": "ID",
"created": "Created at",
"actions": "Actions"
},
"input": {
"resetColor": "Reset Color",

View File

@ -5,15 +5,13 @@ export default class AbstractModel {
/**
* The max right the user has on this object, as returned by the x-max-right header from the api.
* @type {number|null}
dpschen marked this conversation as resolved Outdated

Using ts here improves the DX a lot.

Using ts here improves the DX a lot.

I also started converting the abstractService to ts. But I had too many issues with it so removed it again.

What I currently have:

import axios, {Method} from 'axios'
import {objectToSnakeCase} from '@/helpers/case'
import {getToken} from '@/helpers/auth'
import AbstractModel from '@/models/abstractModel'

interface Paths {
	create : string
	get : string
	getAll : string
	update : string
	delete : string
}

export default class AbstractService {

	/////////////////////////////
	// Initial variable definitions
	///////////////////////////

	http
	loading = false
	uploadProgress = 0
	paths: Paths = {
		create: '',
		get: '',
		getAll: '',
		update: '',
		delete: '',
	}
	// This contains the total number of pages and the number of results for the current page
	totalPages = 0
	resultCount = 0

	/////////////
	// Service init
	///////////

	/**
	 * The abstract constructor.
	 * @param [paths] An object with all paths.
	 */
	constructor(paths : Paths) {
		this.http = axios.create({
			baseURL: window.API_URL,
			headers: { 'Content-Type': 'application/json' },
		})

		// Set the interceptors to process every request
		const self = this
		this.http.interceptors.request.use((config) => {
			switch (config.method) {
				case 'post':
					if (this.useUpdateInterceptor()) {
						config.data = self.beforeUpdate(config.data)
						config.data = objectToSnakeCase(config.data)
					}
					break
				case 'put':
					if (this.useCreateInterceptor()) {
						config.data = self.beforeCreate(config.data)
						config.data = objectToSnakeCase(config.data)
					}
					break
				case 'delete':
					if (this.useDeleteInterceptor()) {
						config.data = self.beforeDelete(config.data)
						config.data = objectToSnakeCase(config.data)
					}
					break
			}
			return config
		})

		// Set the default auth header if we have a token
		const token = getToken()
		if (token !== null) {
			this.http.defaults.headers.common['Authorization'] = `Bearer ${token}`
		}

		if (paths) {
			Object.assign(this.paths, paths)
		}
	}

	/**
	 * Whether or not to use the create interceptor which processes a request payload into json
	 */
	useCreateInterceptor(): boolean {
		return true
	}

	/**
	 * Whether or not to use the update interceptor which processes a request payload into json
	 */
	useUpdateInterceptor(): boolean {
		return true
	}

	/**
	 * Whether or not to use the delete interceptor which processes a request payload into json
	 */
	useDeleteInterceptor(): boolean {
		return true
	}

	/////////////////
	// Helper functions
	///////////////

	/**
	 * Returns an object with all route parameters and their values.
	 */
	getRouteReplacements(route : string, parameters = {}) {
		const replace$$1 = {}
		let pattern = this.getRouteParameterPattern()
		pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g')

		for (let parameter; (parameter = pattern.exec(route)) !== null;) {
			replace$$1[parameter[0]] = parameters[parameter[1]]
		}

		return replace$$1
	}

	/**
	 * Holds the replacement pattern for url paths, can be overwritten by implementations.
	 */
	getRouteParameterPattern(): RegExp {
		return /{([^}]+)}/
	}

	/**
	 * Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters.
	 */
	getReplacedRoute(path : string, pathparams : Object) : string {
		const replacements = this.getRouteReplacements(path, pathparams)
		return Object.entries(replacements).reduce(
			(result, [parameter, value]) => result.replace(parameter, value as string),
			path,
		)
	}

	/**
	 * setLoading is a method which sets the loading variable to true, after a timeout of 100ms.
	 * It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the
	 * case the api returns a response in < 100ms.
	 * But because the timeout is created using setTimeout, it will still trigger even if the request is
	 * already finished, so we return a method to call in that case.
	 */
	setLoading(): Function {
		const timeout = setTimeout(() => {
			this.loading = true
		}, 100)
		return () => {
			clearTimeout(timeout)
			this.loading = false
		}
	}

	//////////////////
	// Default factories
	// It is possible to specify a factory for each type of request.
	// This makes it possible to have different models returned from different routes.
	// Specific factories for each request are completly optional, if these are not specified, the defautl factory is used.
	////////////////

	/**
	 * The modelFactory returns an model from an object.
	 * This one here is the default one, usually the service definitions for a model will override this.
	 */
	modelFactory(data : Object) {
		return new AbstractModel(data)
	}

	/**
	 * This is the model factory for get requests.
	 */
	modelGetFactory(data : Object) {
		return this.modelFactory(data)
	}

	/**
	 * This is the model factory for get all requests.
	 */
	modelGetAllFactory(data : Object) {
		return this.modelFactory(data)
	}

	/**
	 * This is the model factory for create requests.
	 */
	modelCreateFactory(data : Object) {
		return this.modelFactory(data)
	}

	/**
	 * This is the model factory for update requests.
	 */
	modelUpdateFactory(data : Object) {
		return this.modelFactory(data)
	}

	//////////////
	// Preprocessors
	////////////

	/**
	 * Default preprocessor for get requests
	 */
	beforeGet(model : AbstractModel) {
		return model
	}

	/**
	 * Default preprocessor for create requests
	 */
	beforeCreate(model : AbstractModel) {
		return model
	}

	/**
	 * Default preprocessor for update requests
	 */
	beforeUpdate(model : AbstractModel) {
		return model
	}

	/**
	 * Default preprocessor for delete requests
	 */
	beforeDelete(model : AbstractModel) {
		return model
	}

	///////////////
	// Global actions
	/////////////

	/**
	 * Performs a get request to the url specified before.
	 * @param model The model to use. The request path is built using the values from the model.
	 * @param params Optional query parameters
	 */
	get(model : AbstractModel, params = {}) {
		if (this.paths.get === '') {
			throw new Error('This model is not able to get data.')
		}

		return this.getM(this.paths.get, model, params)
	}

	/**
	 * This is a more abstract implementation which only does a get request.
	 * Services which need more flexibility can use this.
	 */
	async getM(url : string, model = new AbstractModel({}), params = {}) {
		const cancel = this.setLoading()

		model = this.beforeGet(model)
		const finalUrl = this.getReplacedRoute(url, model)

		try {
			const response = await this.http.get(finalUrl, {params})
			const result = this.modelGetFactory(response.data)
			result.maxRight = Number(response.headers['x-max-right'])
			return result
		} finally {
			cancel()
		}
	}

	async getBlobUrl(url : string, method = 'GET' as Method, data = {}) {
		const response = await this.http({
			url,
			method,
			responseType: 'blob',
			data,
		})
		return window.URL.createObjectURL(new Blob([response.data]))
	}

	/**
	 * Performs a get request to the url specified before.
	 * The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object.
	 * @param model The model to use. The request path is built using the values from the model.
	 * @param params Optional query parameters
	 * @param page The page to get
	 */
	async getAll(model = new AbstractModel({}), params = {}, page = 1) {
		if (this.paths.getAll === '') {
			throw new Error('This model is not able to get data.')
		}

		params.page = page

		const cancel = this.setLoading()
		model = this.beforeGet(model)
		const finalUrl = this.getReplacedRoute(this.paths.getAll, model)

		try {
			const response = await this.http.get(finalUrl, {params: params})
			this.resultCount = Number(response.headers['x-pagination-result-count'])
			this.totalPages = Number(response.headers['x-pagination-total-pages'])

			if (response.data === null) {
				return []
			}

			if (Array.isArray(response.data)) {
				return response.data.map(entry => this.modelGetAllFactory(entry))
			}
			return this.modelGetAllFactory(response.data)
		} finally {
			cancel()
		}
	}

	/**
	 * Performs a put request to the url specified before
	 * @returns {Promise<any | never>}
	 */
	async create(model : AbstractModel) {
		if (this.paths.create === '') {
			throw new Error('This model is not able to create data.')
		}

		const cancel = this.setLoading()
		const finalUrl = this.getReplacedRoute(this.paths.create, model)

		try {
			const response = await this.http.put(finalUrl, model)
			const result = this.modelCreateFactory(response.data)
			if (typeof model.maxRight !== 'undefined') {
				result.maxRight = model.maxRight
			}
			return result
		} finally {
			cancel()
		}
	}

	/**
	 * An abstract implementation to send post requests.
	 * Services can use this to implement functions to do post requests other than using the update method.
	 */
	async post(url : string, model : AbstractModel) {
		const cancel = this.setLoading()

		try {
			const response = await this.http.post(url, model)
			const result = this.modelUpdateFactory(response.data)
			if (typeof model.maxRight !== 'undefined') {
				result.maxRight = model.maxRight
			}
			return result
		} finally {
			cancel()
		}
	}

	/**
	 * Performs a post request to the update url
	 */
	update(model : AbstractModel) {
		if (this.paths.update === '') {
			throw new Error('This model is not able to update data.')
		}

		const finalUrl = this.getReplacedRoute(this.paths.update, model)
		return this.post(finalUrl, model)
	}

	/**
	 * Performs a delete request to the update url
	 */
	async delete(model : AbstractModel) {
		if (this.paths.delete === '') {
			throw new Error('This model is not able to delete data.')
		}

		const cancel = this.setLoading()
		const finalUrl = this.getReplacedRoute(this.paths.delete, model)

		try {
			const {data} = await this.http.delete(finalUrl, model)
			return data
		} finally {
			cancel()
		}
	}

	/**
	 * Uploads a file to a url.
	 * @param url
	 * @param file
	 * @param fieldName The name of the field the file is uploaded to.
	 */
	uploadFile(url : string, file, fieldName : string) {
		return this.uploadBlob(url, new Blob([file]), fieldName, file.name)
	}

	/**
	 * Uploads a blob to a url.
	 */
	uploadBlob(url : string, blob, fieldName, filename : string) {
		const data = new FormData()
		data.append(fieldName, blob, filename)
		return this.uploadFormData(url, data)
	}

	/**
	 * Uploads a form data object.
	 */
	async uploadFormData(url : string, formData) {
		const cancel = this.setLoading()
		try {
			const response = await this.http.put(
				url,
				formData,
				{
					headers: {
						'Content-Type':
							'multipart/form-data; boundary=' + formData._boundary,
					},
					onUploadProgress: progressEvent => {
						this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
					},
				},
			)
			return this.modelCreateFactory(response.data)
		} finally {
			this.uploadProgress = 0
			cancel()
		}
	}
}
I also started converting the abstractService to ts. But I had too many issues with it so removed it again. What I currently have: ```ts import axios, {Method} from 'axios' import {objectToSnakeCase} from '@/helpers/case' import {getToken} from '@/helpers/auth' import AbstractModel from '@/models/abstractModel' interface Paths { create : string get : string getAll : string update : string delete : string } export default class AbstractService { ///////////////////////////// // Initial variable definitions /////////////////////////// http loading = false uploadProgress = 0 paths: Paths = { create: '', get: '', getAll: '', update: '', delete: '', } // This contains the total number of pages and the number of results for the current page totalPages = 0 resultCount = 0 ///////////// // Service init /////////// /** * The abstract constructor. * @param [paths] An object with all paths. */ constructor(paths : Paths) { this.http = axios.create({ baseURL: window.API_URL, headers: { 'Content-Type': 'application/json' }, }) // Set the interceptors to process every request const self = this this.http.interceptors.request.use((config) => { switch (config.method) { case 'post': if (this.useUpdateInterceptor()) { config.data = self.beforeUpdate(config.data) config.data = objectToSnakeCase(config.data) } break case 'put': if (this.useCreateInterceptor()) { config.data = self.beforeCreate(config.data) config.data = objectToSnakeCase(config.data) } break case 'delete': if (this.useDeleteInterceptor()) { config.data = self.beforeDelete(config.data) config.data = objectToSnakeCase(config.data) } break } return config }) // Set the default auth header if we have a token const token = getToken() if (token !== null) { this.http.defaults.headers.common['Authorization'] = `Bearer ${token}` } if (paths) { Object.assign(this.paths, paths) } } /** * Whether or not to use the create interceptor which processes a request payload into json */ useCreateInterceptor(): boolean { return true } /** * Whether or not to use the update interceptor which processes a request payload into json */ useUpdateInterceptor(): boolean { return true } /** * Whether or not to use the delete interceptor which processes a request payload into json */ useDeleteInterceptor(): boolean { return true } ///////////////// // Helper functions /////////////// /** * Returns an object with all route parameters and their values. */ getRouteReplacements(route : string, parameters = {}) { const replace$$1 = {} let pattern = this.getRouteParameterPattern() pattern = new RegExp(pattern instanceof RegExp ? pattern.source : pattern, 'g') for (let parameter; (parameter = pattern.exec(route)) !== null;) { replace$$1[parameter[0]] = parameters[parameter[1]] } return replace$$1 } /** * Holds the replacement pattern for url paths, can be overwritten by implementations. */ getRouteParameterPattern(): RegExp { return /{([^}]+)}/ } /** * Returns a fully-ready-ready-to-make-a-request-to route with replaced parameters. */ getReplacedRoute(path : string, pathparams : Object) : string { const replacements = this.getRouteReplacements(path, pathparams) return Object.entries(replacements).reduce( (result, [parameter, value]) => result.replace(parameter, value as string), path, ) } /** * setLoading is a method which sets the loading variable to true, after a timeout of 100ms. * It has the timeout to prevent the loading indicator from showing for only a blink of an eye in the * case the api returns a response in < 100ms. * But because the timeout is created using setTimeout, it will still trigger even if the request is * already finished, so we return a method to call in that case. */ setLoading(): Function { const timeout = setTimeout(() => { this.loading = true }, 100) return () => { clearTimeout(timeout) this.loading = false } } ////////////////// // Default factories // It is possible to specify a factory for each type of request. // This makes it possible to have different models returned from different routes. // Specific factories for each request are completly optional, if these are not specified, the defautl factory is used. //////////////// /** * The modelFactory returns an model from an object. * This one here is the default one, usually the service definitions for a model will override this. */ modelFactory(data : Object) { return new AbstractModel(data) } /** * This is the model factory for get requests. */ modelGetFactory(data : Object) { return this.modelFactory(data) } /** * This is the model factory for get all requests. */ modelGetAllFactory(data : Object) { return this.modelFactory(data) } /** * This is the model factory for create requests. */ modelCreateFactory(data : Object) { return this.modelFactory(data) } /** * This is the model factory for update requests. */ modelUpdateFactory(data : Object) { return this.modelFactory(data) } ////////////// // Preprocessors //////////// /** * Default preprocessor for get requests */ beforeGet(model : AbstractModel) { return model } /** * Default preprocessor for create requests */ beforeCreate(model : AbstractModel) { return model } /** * Default preprocessor for update requests */ beforeUpdate(model : AbstractModel) { return model } /** * Default preprocessor for delete requests */ beforeDelete(model : AbstractModel) { return model } /////////////// // Global actions ///////////// /** * Performs a get request to the url specified before. * @param model The model to use. The request path is built using the values from the model. * @param params Optional query parameters */ get(model : AbstractModel, params = {}) { if (this.paths.get === '') { throw new Error('This model is not able to get data.') } return this.getM(this.paths.get, model, params) } /** * This is a more abstract implementation which only does a get request. * Services which need more flexibility can use this. */ async getM(url : string, model = new AbstractModel({}), params = {}) { const cancel = this.setLoading() model = this.beforeGet(model) const finalUrl = this.getReplacedRoute(url, model) try { const response = await this.http.get(finalUrl, {params}) const result = this.modelGetFactory(response.data) result.maxRight = Number(response.headers['x-max-right']) return result } finally { cancel() } } async getBlobUrl(url : string, method = 'GET' as Method, data = {}) { const response = await this.http({ url, method, responseType: 'blob', data, }) return window.URL.createObjectURL(new Blob([response.data])) } /** * Performs a get request to the url specified before. * The difference between this and get() is this one is used to get a bunch of data (an array), not just a single object. * @param model The model to use. The request path is built using the values from the model. * @param params Optional query parameters * @param page The page to get */ async getAll(model = new AbstractModel({}), params = {}, page = 1) { if (this.paths.getAll === '') { throw new Error('This model is not able to get data.') } params.page = page const cancel = this.setLoading() model = this.beforeGet(model) const finalUrl = this.getReplacedRoute(this.paths.getAll, model) try { const response = await this.http.get(finalUrl, {params: params}) this.resultCount = Number(response.headers['x-pagination-result-count']) this.totalPages = Number(response.headers['x-pagination-total-pages']) if (response.data === null) { return [] } if (Array.isArray(response.data)) { return response.data.map(entry => this.modelGetAllFactory(entry)) } return this.modelGetAllFactory(response.data) } finally { cancel() } } /** * Performs a put request to the url specified before * @returns {Promise<any | never>} */ async create(model : AbstractModel) { if (this.paths.create === '') { throw new Error('This model is not able to create data.') } const cancel = this.setLoading() const finalUrl = this.getReplacedRoute(this.paths.create, model) try { const response = await this.http.put(finalUrl, model) const result = this.modelCreateFactory(response.data) if (typeof model.maxRight !== 'undefined') { result.maxRight = model.maxRight } return result } finally { cancel() } } /** * An abstract implementation to send post requests. * Services can use this to implement functions to do post requests other than using the update method. */ async post(url : string, model : AbstractModel) { const cancel = this.setLoading() try { const response = await this.http.post(url, model) const result = this.modelUpdateFactory(response.data) if (typeof model.maxRight !== 'undefined') { result.maxRight = model.maxRight } return result } finally { cancel() } } /** * Performs a post request to the update url */ update(model : AbstractModel) { if (this.paths.update === '') { throw new Error('This model is not able to update data.') } const finalUrl = this.getReplacedRoute(this.paths.update, model) return this.post(finalUrl, model) } /** * Performs a delete request to the update url */ async delete(model : AbstractModel) { if (this.paths.delete === '') { throw new Error('This model is not able to delete data.') } const cancel = this.setLoading() const finalUrl = this.getReplacedRoute(this.paths.delete, model) try { const {data} = await this.http.delete(finalUrl, model) return data } finally { cancel() } } /** * Uploads a file to a url. * @param url * @param file * @param fieldName The name of the field the file is uploaded to. */ uploadFile(url : string, file, fieldName : string) { return this.uploadBlob(url, new Blob([file]), fieldName, file.name) } /** * Uploads a blob to a url. */ uploadBlob(url : string, blob, fieldName, filename : string) { const data = new FormData() data.append(fieldName, blob, filename) return this.uploadFormData(url, data) } /** * Uploads a form data object. */ async uploadFormData(url : string, formData) { const cancel = this.setLoading() try { const response = await this.http.put( url, formData, { headers: { 'Content-Type': 'multipart/form-data; boundary=' + formData._boundary, }, onUploadProgress: progressEvent => { this.uploadProgress = Math.round((progressEvent.loaded * 100) / progressEvent.total) }, }, ) return this.modelCreateFactory(response.data) } finally { this.uploadProgress = 0 cancel() } } } ```

There is a lot of stuff where I'm not sure how to do this correctly. To give an example:

  • When you create a model the type of the model depends on the service, but all of these classes extend AbstractModel.
  • Right now I simply added AbstractModel as type, but that obviously doesn't work.
There is a lot of stuff where I'm not sure how to do this correctly. To give an example: - When you create a model the type of the model depends on the service, but all of these classes extend AbstractModel. - Right now I simply added `AbstractModel` as type, but that obviously doesn't work.

I had some progress with this, but was not able to succeed yet.
Since this has nothing to do with the pull request I'm resolving this.

I had some progress with this, but was not able to succeed yet. Since this has nothing to do with the pull request I'm resolving this.
*/
maxRight = null
maxRight: number | null = null
/**
* The abstract constructor takes an object and merges its data with the default data of this model.
* @param data
*/
constructor(data) {
constructor(data : Object = {}) {
data = objectToCamelCase(data)
// Put all data in our model while overriding those with a value of null or undefined with their defaults
@ -26,9 +24,8 @@ export default class AbstractModel {
/**
* Default attributes that define the "empty" state.
* @return {{}}
*/
defaults() {
defaults(): Object {
return {}
}
}

15
src/models/caldavToken.ts Normal file
View File

@ -0,0 +1,15 @@
import AbstractModel from './abstractModel'
export default class CaldavTokenModel extends AbstractModel {
constructor(data? : Object) {
dpschen marked this conversation as resolved Outdated

Since AbstractModel already defines a defaults function we don't have to implement one here.
For better ts support we can define fields

Since `AbstractModel` already defines a `defaults` function we don't have to implement one here. For better ts support we can define [fields](https://www.typescriptlang.org/docs/handbook/2/classes.html#fields)
super(data)
/** @type {number} */
this.id
if (this.created) {
/** @type {Date} */
this.created = new Date(this.created)
}
}
}

View File

@ -0,0 +1,25 @@
import {formatISO} from 'date-fns'
import CaldavTokenModel from '../models/caldavToken'
import AbstractService from './abstractService'
export default class CaldavTokenService extends AbstractService {

I think we should also use the correct casing here (file and class name). What do you think?

I think we should also use the correct casing here (file and class name). What do you think?

Sounds like a good idea.

Sounds like a good idea.
constructor() {
super({
getAll: '/user/settings/token/caldav',
create: '/user/settings/token/caldav',
delete: '/user/settings/token/caldav/{id}',
})
}
processModel(model) {
return {
...model,
created: formatISO(new Date(model.created)),
}
}
modelFactory(data) {
return new CaldavTokenModel(data)
}
}

View File

@ -16,41 +16,94 @@
/>
</div>
</div>
<h5 class="mt-5 mb-4 has-text-weight-bold">
{{ $t('user.settings.caldav.tokens') }}
</h5>
<p>
<a href="https://vikunja.io/docs/caldav/" rel="noreferrer noopener nofollow" target="_blank">
{{ isLocalUser ? $t('user.settings.caldav.tokensHowTo') : $t('user.settings.caldav.mustUseToken') }}
<template v-if="!isLocalUser">
<br/>
<i18n-t keypath="user.settings.caldav.usernameIs">
<strong>{{ username }}</strong>
</i18n-t>
</template>
</p>
<table class="table" v-if="tokens.length > 0">
<tr>
<th>{{ $t('misc.id') }}</th>
<th>{{ $t('misc.created') }}</th>
<th class="has-text-right">{{ $t('misc.actions') }}</th>
</tr>
<tr v-for="tk in tokens" :key="tk.id">
<td>{{ tk.id }}</td>
<td>{{ formatDateShort(tk.created) }}</td>
<td class="has-text-right">
<x-button type="secondary" @click="deleteToken(tk)">
{{ $t('misc.delete') }}
</x-button>
</td>
</tr>
</table>
<Message v-if="newToken" class="mb-4">
{{ $t('user.settings.caldav.tokenCreated', {token: newToken.token}) }}<br/>
{{ $t('user.settings.caldav.wontSeeItAgain') }}
</Message>
<x-button icon="plus" class="mb-4" @click="createToken" :loading="service.loading">
{{ $t('user.settings.caldav.createToken') }}
</x-button>
<p>
<BaseButton :href="CALDAV_DOCS" target="_blank">
dpschen marked this conversation as resolved Outdated

In case <BaseButton> is a link (not a router-link):

Do you think we always want rel="noreferrer noopener nofollow" to be set?
The current default rel for links is only noopener (because copied from my own code).

In case `<BaseButton>` is a link (**not** a `router-link`): Do you think we always want `rel="noreferrer noopener nofollow"` to be set? The current default `rel` for links is only `noopener` (because copied from my own code).

Yeah, that sounds like a good idea.

Yeah, that sounds like a good idea.

Solved in #1491

Solved in https://kolaente.dev/vikunja/frontend/pulls/1491
{{ $t('user.settings.caldav.more') }}
</a>
</BaseButton>
</p>
</card>
</template>
<script lang="ts">
import {defineComponent} from 'vue'
<script lang="ts" setup>
import copy from 'copy-to-clipboard'
import {mapState} from 'vuex'
import {CALDAV_DOCS} from '@/urls'
import {computed, ref, shallowReactive} from 'vue'
import {useI18n} from 'vue-i18n'
import {useStore} from 'vuex'
export default defineComponent({
name: 'user-settings-caldav',
data() {
return {
caldavDocsUrl: CALDAV_DOCS,
}
},
mounted() {
this.setTitle(`${this.$t('user.settings.caldav.title')} - ${this.$t('user.settings.title')}`)
},
computed: {
caldavUrl() {
return `${this.$store.getters['config/apiBase']}/dav/principals/${this.userInfo.username}/`
},
...mapState('config', ['caldavEnabled']),
...mapState({
userInfo: state => state.auth.info,
}),
},
methods: {
copy,
},
import {CALDAV_DOCS} from '@/urls'
import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import BaseButton from '@/components/base/BaseButton.vue'
import Message from '@/components/misc/message.vue'
import CaldavTokenService from '@/services/caldavToken'
import CaldavTokenModel from '@/models/caldavToken'
const {t} = useI18n()
dpschen marked this conversation as resolved Outdated

I think we should always start the view components with the title composable.
Sometimes data fetching might need to be first of course.

I think we should always start the view components with the title composable. Sometimes data fetching might need to be first of course.
useTitle(() => `${t('user.settings.caldav.title')} - ${t('user.settings.title')}`)
const service = shallowReactive(new CaldavTokenService())

I think (not sure) that using some kind of reactive for stuff like this is a good pattern, because this way you can always extract parts in a composable if needed later. Often we want to do this after a component has grown in size because some stuff was added.

I think (not sure) that using some kind of reactive for stuff like this is a good pattern, because this way you can always extract parts in a composable if needed later. Often we want to do this after a component has grown in size because some stuff was added.
const tokens = ref<CaldavTokenModel[]>([])
service.getAll().then((result: CaldavTokenModel[]) => {

I decided to flatten this again. Sorry for this… I'm also still lerning the composition api =)
Originally I thought that this might be reused in other components.

I decided to flatten this again. Sorry for this… I'm also still lerning the composition api =) Originally I thought that this might be reused in other components.
tokens.value = result
})
const newToken = ref<CaldavTokenModel>()
async function createToken() {
newToken.value = await service.create({}) as CaldavTokenModel
tokens.value.push(newToken.value)
}
async function deleteToken(token: CaldavTokenModel) {
const r = await service.delete(token)
tokens.value = tokens.value.filter(({id}) => id !== token.id)
dpschen marked this conversation as resolved Outdated

Not sure if this works aswell.
From vue's perspective filtering and reassigning should have almost the same speed.

But I was not sure if this makes sense, since I am still calling success even if tokens stays the same after filtering because the condition never succeded.

Not sure if this works aswell. From vue's perspective filtering and reassigning should have almost the same speed. But I was not sure if this makes sense, since I am still calling success even if tokens stays the same after filtering because the condition never succeded.

But wouldn't the condition only never succeed when the token which was deleted is not present in the list? That case should never happen. Because of this, I think showing the success message regardless is not wrong here.

But wouldn't the condition only never succeed when the token which was deleted is not present in the list? That case should never happen. Because of this, I think showing the success message regardless is not wrong here.
success(r)
}
const store = useStore()
const username = computed(() => store.state.auth.info?.username)
const caldavUrl = computed(() => `${store.getters['config/apiBase']}/dav/principals/${username.value}/`)
dpschen marked this conversation as resolved Outdated

I reused the username computed here

I reused the `username` computed here
const caldavEnabled = computed(() => store.state.config.caldavEnabled)
const isLocalUser = computed(() => store.state.auth.info?.isLocalUser)
</script>