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/i18n/lang/en.json b/src/i18n/lang/en.json index be6bf4ed6..ca93154db 100644 --- a/src/i18n/lang/en.json +++ b/src/i18n/lang/en.json @@ -139,6 +139,30 @@ "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", + "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.", + "titleRequired": "The title is required", + "expired": "This token has expired {ago}.", + "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", + "expiresAt": "Expires at", + "permissions": "Permissions" + } } }, "deletion": { diff --git a/src/modelTypes/IApiToken.ts b/src/modelTypes/IApiToken.ts new file mode 100644 index 000000000..189549c4d --- /dev/null +++ b/src/modelTypes/IApiToken.ts @@ -0,0 +1,14 @@ +import type {IAbstract} from '@/modelTypes/IAbstract' + +export interface IApiPermission { + [key: string]: string[] +} + +export interface IApiToken extends IAbstract { + id: number + title: string + token: string + permissions: IApiPermission + expiresAt: Date + created: Date +} diff --git a/src/models/apiTokenModel.ts b/src/models/apiTokenModel.ts new file mode 100644 index 000000000..20d21c1eb --- /dev/null +++ b/src/models/apiTokenModel.ts @@ -0,0 +1,21 @@ +import AbstractModel from '@/models/abstractModel' +import type {IApiToken} from '@/modelTypes/IApiToken' + +export default class ApiTokenModel extends AbstractModel { + id = 0 + title = '' + 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..ade111970 --- /dev/null +++ b/src/services/apiToken.ts @@ -0,0 +1,36 @@ +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) + } + + 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.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..1ff4f1ee2 --- /dev/null +++ b/src/views/user/settings/ApiTokens.vue @@ -0,0 +1,254 @@ + + +