From a20eef245374be7c19d0becc9533e3e37fdc8345 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 11:15:48 +0200 Subject: [PATCH 1/9] feat(api tokens): add basic api token overview --- src/i18n/lang/en.json | 11 +++++ src/modelTypes/IApiToken.ts | 13 +++++ src/models/apiTokenModel.ts | 20 ++++++++ src/router/index.ts | 6 +++ src/services/apiToken.ts | 25 ++++++++++ src/views/user/Settings.vue | 4 ++ src/views/user/settings/ApiTokens.vue | 69 +++++++++++++++++++++++++++ 7 files changed, 148 insertions(+) create mode 100644 src/modelTypes/IApiToken.ts create mode 100644 src/models/apiTokenModel.ts create mode 100644 src/services/apiToken.ts create mode 100644 src/views/user/settings/ApiTokens.vue diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index be6bf4ed6..538a62a75 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -139,6 +139,17 @@ "system": "System", "dark": "Dark" } + }, + "apiTokens": { + "title": "API Tokens", + "general": "API tokens allow you to use Vikunja's api without user credentials.", + "apiDocs": "Check out the api docs", + "createToken": "Create a token", + "attributes": { + "title": "Title", + "expiresAt": "Expires at", + "permissions": "Permissions" + } } }, "deletion": { diff --git a/src/modelTypes/IApiToken.ts b/src/modelTypes/IApiToken.ts new file mode 100644 index 000000000..842e242ad --- /dev/null +++ b/src/modelTypes/IApiToken.ts @@ -0,0 +1,13 @@ +import type {IAbstract} from '@/modelTypes/IAbstract' + +export interface IApiPermission { + [key: string]: string[] +} + +export interface IApiToken extends IAbstract { + id: number + token: string + permissions: IApiPermission + expiresAt: Date + created: Date +} \ No newline at end of file diff --git a/src/models/apiTokenModel.ts b/src/models/apiTokenModel.ts new file mode 100644 index 000000000..dc3d69f17 --- /dev/null +++ b/src/models/apiTokenModel.ts @@ -0,0 +1,20 @@ +import AbstractModel from '@/models/abstractModel' +import type {IApiToken} from '@/modelTypes/IApiToken' + +export default class ApiTokenModel extends AbstractModel { + id = 0 + token = '' + permissions = null + expiresAt: Date = null + created: Date = null + + constructor(data: Partial) { + super() + + this.assignData(data) + + this.expiresAt = new Date(this.expiresAt) + this.created = new Date(this.created) + this.updated = new Date(this.updated) + } +} \ No newline at end of file diff --git a/src/router/index.ts b/src/router/index.ts index 3aaa786e9..d00b6d4c4 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -65,6 +65,7 @@ const UserSettingsEmailUpdateComponent = () => import('@/views/user/settings/Ema const UserSettingsGeneralComponent = () => import('@/views/user/settings/General.vue') const UserSettingsPasswordUpdateComponent = () => import('@/views/user/settings/PasswordUpdate.vue') const UserSettingsTOTPComponent = () => import('@/views/user/settings/TOTP.vue') +const UserSettingsApiTokensComponent = () => import('@/views/user/settings/ApiTokens.vue') // Project Handling const NewProjectComponent = () => import('@/views/project/NewProject.vue') @@ -183,6 +184,11 @@ const router = createRouter({ name: 'user.settings.totp', component: UserSettingsTOTPComponent, }, + { + path: '/user/settings/api-tokens', + name: 'user.settings.apiTokens', + component: UserSettingsApiTokensComponent, + }, ], }, { diff --git a/src/services/apiToken.ts b/src/services/apiToken.ts new file mode 100644 index 000000000..5c29370e3 --- /dev/null +++ b/src/services/apiToken.ts @@ -0,0 +1,25 @@ +import AbstractService from '@/services/abstractService' +import type {IApiToken} from '@/modelTypes/IApiToken' +import ApiTokenModel from '@/models/apiTokenModel' + +export default class ApiTokenService extends AbstractService { + constructor() { + super({ + create: '/tokens', + getAll: '/tokens', + delete: '/tokens/{id}', + }) + } + + processModel(model: IApiToken) { + return { + ...model, + expiresAt: new Date(model.expiresAt).toISOString(), + created: new Date(model.created).toISOString(), + } + } + + modelFactory(data: Partial) { + return new ApiTokenModel(data) + } +} \ No newline at end of file diff --git a/src/views/user/Settings.vue b/src/views/user/Settings.vue index 0af37c141..4403e3650 100644 --- a/src/views/user/Settings.vue +++ b/src/views/user/Settings.vue @@ -75,6 +75,10 @@ const navigationItems = computed(() => { routeName: 'user.settings.caldav', condition: caldavEnabled.value, }, + { + title: t('user.settings.apiTokens.title'), + routeName: 'user.settings.apiTokens', + }, { title: t('user.deletion.title'), routeName: 'user.settings.deletion', diff --git a/src/views/user/settings/ApiTokens.vue b/src/views/user/settings/ApiTokens.vue new file mode 100644 index 000000000..2ae763f0c --- /dev/null +++ b/src/views/user/settings/ApiTokens.vue @@ -0,0 +1,69 @@ + + + + + \ No newline at end of file From e47ad021a3c7f7ea9d93730555bc745358e206ca Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 12:47:32 +0200 Subject: [PATCH 2/9] feat(api tokens): add token creation form --- src/i18n/lang/en.json | 9 +- src/modelTypes/IApiToken.ts | 2 +- src/models/apiTokenModel.ts | 2 +- src/services/apiToken.ts | 11 +++ src/views/user/settings/ApiTokens.vue | 127 +++++++++++++++++++++++--- 5 files changed, 134 insertions(+), 17 deletions(-) diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 538a62a75..9afb29e84 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -142,9 +142,14 @@ }, "apiTokens": { "title": "API Tokens", - "general": "API tokens allow you to use Vikunja's api without user credentials.", + "general": "API tokens allow you to use Vikunja's API without user credentials.", "apiDocs": "Check out the api docs", - "createToken": "Create a token", + "createAToken": "Create a token", + "createToken": "Create token", + "30d": "30 Days", + "60d": "60 Days", + "90d": "90 Days", + "permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.", "attributes": { "title": "Title", "expiresAt": "Expires at", diff --git a/src/modelTypes/IApiToken.ts b/src/modelTypes/IApiToken.ts index 842e242ad..ce2627b63 100644 --- a/src/modelTypes/IApiToken.ts +++ b/src/modelTypes/IApiToken.ts @@ -10,4 +10,4 @@ export interface IApiToken extends IAbstract { permissions: IApiPermission expiresAt: Date created: Date -} \ No newline at end of file +} diff --git a/src/models/apiTokenModel.ts b/src/models/apiTokenModel.ts index dc3d69f17..8bcc277ca 100644 --- a/src/models/apiTokenModel.ts +++ b/src/models/apiTokenModel.ts @@ -8,7 +8,7 @@ export default class ApiTokenModel extends AbstractModel { expiresAt: Date = null created: Date = null - constructor(data: Partial) { + constructor(data: Partial = {}) { super() this.assignData(data) diff --git a/src/services/apiToken.ts b/src/services/apiToken.ts index 5c29370e3..ade111970 100644 --- a/src/services/apiToken.ts +++ b/src/services/apiToken.ts @@ -22,4 +22,15 @@ export default class ApiTokenService extends AbstractService { modelFactory(data: Partial) { return new ApiTokenModel(data) } + + async getAvailableRoutes() { + const cancel = this.setLoading() + + try { + const response = await this.http.get('/routes') + return response.data + } finally { + cancel() + } + } } \ No newline at end of file diff --git a/src/views/user/settings/ApiTokens.vue b/src/views/user/settings/ApiTokens.vue index 2ae763f0c..685ed5d72 100644 --- a/src/views/user/settings/ApiTokens.vue +++ b/src/views/user/settings/ApiTokens.vue @@ -1,34 +1,78 @@ - - \ No newline at end of file From 021f92303db0a7c498a50405a572deed94f2d288 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 12:56:23 +0200 Subject: [PATCH 3/9] feat(api tokens): validate title field when creating a new token --- src/i18n/lang/en.json | 2 ++ src/modelTypes/IApiToken.ts | 1 + src/models/apiTokenModel.ts | 1 + src/views/user/settings/ApiTokens.vue | 35 +++++++++++++++++++-------- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index 9afb29e84..e94eff8f5 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -150,8 +150,10 @@ "60d": "60 Days", "90d": "90 Days", "permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.", + "titleRequired": "The title is required", "attributes": { "title": "Title", + "titlePlaceholder": "Enter a title you will recognize later", "expiresAt": "Expires at", "permissions": "Permissions" } diff --git a/src/modelTypes/IApiToken.ts b/src/modelTypes/IApiToken.ts index ce2627b63..189549c4d 100644 --- a/src/modelTypes/IApiToken.ts +++ b/src/modelTypes/IApiToken.ts @@ -6,6 +6,7 @@ export interface IApiPermission { export interface IApiToken extends IAbstract { id: number + title: string token: string permissions: IApiPermission expiresAt: Date diff --git a/src/models/apiTokenModel.ts b/src/models/apiTokenModel.ts index 8bcc277ca..20d21c1eb 100644 --- a/src/models/apiTokenModel.ts +++ b/src/models/apiTokenModel.ts @@ -3,6 +3,7 @@ import type {IApiToken} from '@/modelTypes/IApiToken' export default class ApiTokenModel extends AbstractModel { id = 0 + title = '' token = '' permissions = null expiresAt: Date = null diff --git a/src/views/user/settings/ApiTokens.vue b/src/views/user/settings/ApiTokens.vue index 685ed5d72..4e35092c1 100644 --- a/src/views/user/settings/ApiTokens.vue +++ b/src/views/user/settings/ApiTokens.vue @@ -16,6 +16,8 @@ const availableRoutes = ref(null) const newToken = ref(new ApiTokenModel()) const newTokenExpiry = ref(30) const newTokenPermissions = ref({}) +const newTokenTitleValid = ref(true) +const apiTokenTitle = ref() onMounted(async () => { tokens.value = await service.getAll() @@ -38,12 +40,17 @@ function deleteToken() { } async function createToken() { + if (!newTokenTitleValid.value) { + apiTokenTitle.value.focus() + return + } + const expiry = Number(newTokenExpiry.value) - if(!isNaN(expiry)) { + if (!isNaN(expiry)) { // if it's a number, we assume it's the number of days in the future newToken.value.expiresAt = new Date((new Date()) + expiry * MILLISECONDS_A_DAY) } - + newToken.value.permissions = {} Object.entries(newTokenPermissions.value).forEach(([key, ps]) => { const all = Object.entries(ps) @@ -54,7 +61,7 @@ async function createToken() { newToken.value.permissions[key] = all } }) - + const token = await service.create(newToken.value) newToken.value = new ApiTokenModel() newTokenExpiry.value = 30 @@ -113,19 +120,27 @@ async function createToken() { + :placeholder="$t('user.settings.apiTokens.attributes.titlePlaceholder')" + v-model="newToken.title" + @keyup="() => newTokenTitleValid = newToken.title !== ''" + @focusout="() => newTokenTitleValid = newToken.title !== ''" + /> +

+ {{ $t('user.settings.apiTokens.titleRequired') }} +

- +
- @@ -140,9 +155,9 @@ async function createToken() {

{{ $t('user.settings.apiTokens.permissionExplanation') }}

{{ group }}
- From 0bb85870db83173ff61daa499ca00085afa5a5c4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 13:07:20 +0200 Subject: [PATCH 4/9] feat(api tokens): allow custom selection of expiry dates --- src/components/misc/flatpickr/Flatpickr.vue | 56 ++++++++++----------- src/views/user/settings/ApiTokens.vue | 48 +++++++++++++++--- 2 files changed, 68 insertions(+), 36 deletions(-) diff --git a/src/components/misc/flatpickr/Flatpickr.vue b/src/components/misc/flatpickr/Flatpickr.vue index 78f171d91..fded8fd65 100644 --- a/src/components/misc/flatpickr/Flatpickr.vue +++ b/src/components/misc/flatpickr/Flatpickr.vue @@ -1,10 +1,10 @@ @@ -20,39 +20,39 @@ type Options = flatpickr.Options.Options type DateOption = flatpickr.Options.DateOption function camelToKebab(string: string) { - return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + return string.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() } function arrayify(obj: T) { - return obj instanceof Array + return obj instanceof Array ? obj : [obj] } function nullify(value: T) { - return (value && (value as unknown[]).length) + return (value && (value as unknown[]).length) ? value : null } // Events to emit, copied from flatpickr source const includedEvents = [ - 'onChange', - 'onClose', - 'onDestroy', - 'onMonthChange', - 'onOpen', - 'onYearChange', + 'onChange', + 'onClose', + 'onDestroy', + 'onMonthChange', + 'onOpen', + 'onYearChange', ] as HookKey[] // Let's not emit these events by default const excludedEvents = [ - 'onValueUpdate', - 'onDayCreate', - 'onParseConfig', - 'onReady', - 'onPreCalendarPosition', - 'onKeyDown', + 'onValueUpdate', + 'onDayCreate', + 'onParseConfig', + 'onReady', + 'onPreCalendarPosition', + 'onKeyDown', ] as HookKey[] // Keep a copy of all events for later use @@ -100,19 +100,19 @@ const attrs = useAttrs() const root = ref(null) const fp = ref(null) -const safeConfig = ref({ ...props.config }) +const safeConfig = ref({...props.config}) function prepareConfig() { // Don't mutate original object on parent component - const newConfig: Options = { ...props.config } + const newConfig: Options = {...props.config} props.events.forEach((hook) => { // Respect global callbacks registered via setDefault() method const globalCallbacks = flatpickr.defaultConfig[hook] || [] - + // Inject our own method along with user callback const localCallback: Hook = (...args) => emit(camelToKebab(hook), ...args) - + // Overwrite with merged array newConfig[hook] = arrayify(newConfig[hook] || []).concat( globalCallbacks, @@ -147,9 +147,9 @@ onMounted(() => { prepareConfig() /** - * Get the HTML node where flatpickr to be attached - * Bind on parent element if wrap is true - */ + * Get the HTML node where flatpickr to be attached + * Bind on parent element if wrap is true + */ const element = props.config.wrap ? root.value.parentNode : root.value @@ -179,7 +179,7 @@ watch(config, () => { fp.value.set(name, safeConfig.value[name]) } }) -}, {deep:true}) +}, {deep: true}) const fpInput = computed(() => { if (!fp.value) return @@ -198,8 +198,8 @@ watchEffect(() => fpInput.value?.addEventListener('blur', onBlur)) onBeforeUnmount(() => fpInput.value?.removeEventListener('blur', onBlur)) /** -* Watch for the disabled property and sets the value to the real input. -*/ + * Watch for the disabled property and sets the value to the real input. + */ watchEffect(() => { if (disabled.value) { fpInput.value?.setAttribute('disabled', '') diff --git a/src/views/user/settings/ApiTokens.vue b/src/views/user/settings/ApiTokens.vue index 4e35092c1..237341738 100644 --- a/src/views/user/settings/ApiTokens.vue +++ b/src/views/user/settings/ApiTokens.vue @@ -7,6 +7,10 @@ import BaseButton from '@/components/base/BaseButton.vue' import ApiTokenModel from '@/models/apiTokenModel' import Fancycheckbox from '@/components/input/fancycheckbox.vue' import {MILLISECONDS_A_DAY} from '@/constants/date' +import flatPickr from 'vue-flatpickr-component' +import 'flatpickr/dist/flatpickr.css' +import {useI18n} from 'vue-i18n' +import {useAuthStore} from '@/stores/auth' const service = new ApiTokenService() const tokens = ref([]) @@ -15,10 +19,27 @@ const showCreateForm = ref(false) const availableRoutes = ref(null) const newToken = ref(new ApiTokenModel()) const newTokenExpiry = ref(30) +const newTokenExpiryCustom = ref(new Date()) const newTokenPermissions = ref({}) const newTokenTitleValid = ref(true) const apiTokenTitle = ref() +const {t} = useI18n() +const authStore = useAuthStore() + +const now = new Date() +const flatPickerConfig = computed(() => ({ + altFormat: t('date.altFormatLong'), + altInput: true, + dateFormat: 'Y-m-d H:i', + enableTime: true, + time_24hr: true, + locale: { + firstDayOfWeek: authStore.settings.weekStart, + }, + minDate: now, +})) + onMounted(async () => { tokens.value = await service.getAll() availableRoutes.value = await service.getAvailableRoutes() @@ -44,11 +65,13 @@ async function createToken() { apiTokenTitle.value.focus() return } - + const expiry = Number(newTokenExpiry.value) if (!isNaN(expiry)) { // if it's a number, we assume it's the number of days in the future newToken.value.expiresAt = new Date((new Date()) + expiry * MILLISECONDS_A_DAY) + } else { + newToken.value.expiresAt = new Date(newTokenExpiryCustom.value) } newToken.value.permissions = {} @@ -65,6 +88,7 @@ async function createToken() { const token = await service.create(newToken.value) newToken.value = new ApiTokenModel() newTokenExpiry.value = 30 + newTokenExpiryCustom.value = new Date() resetPermissions() tokens.value.push(token) showCreateForm.value = false @@ -139,13 +163,21 @@ async function createToken() { -
- +
+
+ +
+
From bd7b973559ccc0a373a9bb83407117631723fd47 Mon Sep 17 00:00:00 2001 From: kolaente Date: Fri, 1 Sep 2023 13:18:00 +0200 Subject: [PATCH 5/9] feat(api tokens): add deleting api tokens --- src/i18n/lang/en.json | 5 ++++ src/views/user/settings/ApiTokens.vue | 34 ++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/src/i18n/lang/en.json b/src/i18n/lang/en.json index e94eff8f5..d67014b2a 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -151,6 +151,11 @@ "90d": "90 Days", "permissionExplanation": "Permissions allow you to scope what an api token is allowed to do.", "titleRequired": "The title is required", + "delete": { + "header": "Delete this token", + "text1": "Are you sure you want to delete the token \"{token}\"?", + "text2": "This will revoke access to all applications or integrations using it. You cannot undo this." + }, "attributes": { "title": "Title", "titlePlaceholder": "Enter a title you will recognize later", diff --git a/src/views/user/settings/ApiTokens.vue b/src/views/user/settings/ApiTokens.vue index 237341738..98ebb9cc3 100644 --- a/src/views/user/settings/ApiTokens.vue +++ b/src/views/user/settings/ApiTokens.vue @@ -24,6 +24,9 @@ const newTokenPermissions = ref({}) const newTokenTitleValid = ref(true) const apiTokenTitle = ref() +const showDeleteModal = ref(false) +const tokenToDelete = ref(null) + const {t} = useI18n() const authStore = useAuthStore() @@ -57,7 +60,15 @@ function resetPermissions() { }) } -function deleteToken() { +async function deleteToken() { + await service.delete(tokenToDelete.value) + showDeleteModal.value = false + tokenToDelete.value = null + const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id) + if (index === -1) { + return + } + tokens.value.splice(index, 1) } async function createToken() { @@ -117,7 +128,7 @@ async function createToken() { {{ tk.id }} {{ tk.title }} -