fix: address ts errors in user and team views

This commit is contained in:
kolaente
2025-06-04 23:20:59 +02:00
parent 8ffbfa112c
commit e14bd817bc
21 changed files with 193 additions and 151 deletions

View File

@@ -39,7 +39,7 @@ import ContentLinkShare from '@/components/home/ContentLinkShare.vue'
import NoAuthWrapper from '@/components/misc/NoAuthWrapper.vue'
import Ready from '@/components/misc/Ready.vue'
import {setLanguage} from '@/i18n'
import {setLanguage, getBrowserLanguage} from '@/i18n'
import {useAuthStore} from '@/stores/auth'
import {useBaseStore} from '@/stores/base'
@@ -88,7 +88,7 @@ watch(userEmailConfirm, (userEmailConfirm) => {
router.push({name: 'user.login'})
}, { immediate: true })
setLanguage(authStore.settings.language)
setLanguage(authStore.settings.language ?? getBrowserLanguage())
useColorScheme()
</script>

View File

@@ -1,10 +1,10 @@
<script lang="ts" setup>
import {logEvent} from 'histoire/client'
import {reactive} from 'vue'
import {reactive, type App} from 'vue'
import {createRouter, createMemoryHistory} from 'vue-router'
import BaseButton from './BaseButton.vue'
function setupApp({ app }) {
function setupApp({ app }: { app: App }) {
// Router mock
app.use(createRouter({
history: createMemoryHistory(),

View File

@@ -83,47 +83,51 @@ function forceLayout(el: HTMLElement) {
# see: https://vuejs.org/guide/built-ins/transition.html#javascript-hooks
###################################################################### */
function beforeEnter(el: HTMLElement) {
el.style.height = '0'
el.style.willChange = 'height'
el.style.backfaceVisibility = 'hidden'
forceLayout(el)
function beforeEnter(el: Element) {
const element = el as HTMLElement
element.style.height = '0'
element.style.willChange = 'height'
element.style.backfaceVisibility = 'hidden'
forceLayout(element)
}
// the done callback is optional when
// used in combination with CSS
function enter(el: HTMLElement) {
const height = getHeight(el) // Get the natural height
el.style.height = height // Update the height
function enter(el: Element) {
const element = el as HTMLElement
const height = getHeight(element) // Get the natural height
element.style.height = height // Update the height
}
function afterEnter(el: HTMLElement) {
removeHeight(el)
function afterEnter(el: Element) {
removeHeight(el as HTMLElement)
}
function enterCancelled(el: HTMLElement) {
removeHeight(el)
function enterCancelled(el: Element) {
removeHeight(el as HTMLElement)
}
function beforeLeave(el: HTMLElement) {
function beforeLeave(el: Element) {
const element = el as HTMLElement
// Give the element a height to change from
el.style.height = `${el.scrollHeight}px`
forceLayout(el)
element.style.height = `${element.scrollHeight}px`
forceLayout(element)
}
function leave(el: HTMLElement) {
function leave(el: Element) {
const element = el as HTMLElement
// Set the height back to 0
el.style.height = '0'
el.style.willChange = ''
el.style.backfaceVisibility = ''
element.style.height = '0'
element.style.willChange = ''
element.style.backfaceVisibility = ''
}
function afterLeave(el: HTMLElement) {
removeHeight(el)
function afterLeave(el: Element) {
removeHeight(el as HTMLElement)
}
function leaveCancelled(el: HTMLElement) {
removeHeight(el)
function leaveCancelled(el: Element) {
removeHeight(el as HTMLElement)
}
function removeHeight(el: HTMLElement) {

View File

@@ -1,7 +1,7 @@
/**
* Make date objects from timestamps
*/
export function parseDateOrNull(date: string | Date) {
export function parseDateOrNull(date: string | Date | null | undefined) {
if (date instanceof Date) {
return date
}

View File

@@ -1,6 +1,6 @@
import type {IAbstract} from './IAbstract'
export type AvatarProvider = 'default' | 'initials' | 'gravatar' | 'marble' | 'upload'
export type AvatarProvider = 'default' | 'initials' | 'gravatar' | 'marble' | 'upload' | 'ldap'
export interface IAvatar extends IAbstract {
avatarProvider: AvatarProvider

View File

@@ -1,7 +1,12 @@
import type {IAbstract} from './IAbstract'
export interface ICaldavToken extends IAbstract {
id: number;
id: number;
created: Date;
created: Date;
/**
* The actual token value is only returned when creating a new token.
*/
token?: string;
}

View File

@@ -21,6 +21,7 @@ export interface IUser extends IAbstract {
updated: Date
settings: IUserSettings
isLocalUser: boolean
deletionScheduledAt: string | Date | null
isLocalUser: boolean
deletionScheduledAt: string | Date | null
authProvider?: string
}

View File

@@ -5,9 +5,9 @@ export default class ApiTokenModel extends AbstractModel<IApiToken> {
id = 0
title = ''
token = ''
permissions = null
expiresAt: Date = null
created: Date = null
permissions = {}
expiresAt: Date = new Date()
created: Date = new Date()
constructor(data: Partial<IApiToken> = {}) {
super()

View File

@@ -271,6 +271,7 @@ import User from '@/components/misc/User.vue'
import TeamService from '@/services/team'
import TeamMemberService from '@/services/teamMember'
import UserService from '@/services/user'
import UserModel from '@/models/user'
import {RIGHTS as Rights} from '@/constants/rights'
@@ -305,8 +306,8 @@ const userService = ref<UserService>(new UserService())
const team = ref<ITeam>()
const teamId = computed(() => Number(route.params.id))
const memberToDelete = ref<ITeamMember>()
const newMember = ref<IUser>()
const foundUsers = ref<IUser[]>()
const newMember = ref<IUser | null>(null)
const foundUsers = ref<IUser[]>([])
const showDeleteModal = ref(false)
const showUserDeleteModal = ref(false)
@@ -331,22 +332,22 @@ async function save() {
}
showErrorTeamnameRequired.value = false
team.value = await teamService.value.update(team.value)
team.value = await teamService.value.update(team.value as ITeam)
success({message: t('team.edit.success')})
}
async function deleteTeam() {
await teamService.value.delete(team.value)
await teamService.value.delete(team.value as ITeam)
success({message: t('team.edit.delete.success')})
router.push({name: 'teams.index'})
}
async function deleteMember() {
try {
await teamMemberService.value.delete({
teamId: teamId.value,
username: memberToDelete.value.username,
})
await teamMemberService.value.delete({
teamId: teamId.value,
username: memberToDelete.value?.username ?? '',
} as unknown as ITeamMember)
success({message: t('team.edit.deleteUser.success')})
await loadTeam()
} finally {
@@ -360,11 +361,11 @@ async function addUser() {
showMustSelectUserError.value = true
return
}
await teamMemberService.value.create({
teamId: teamId.value,
username: newMember.value.username,
})
newMember.value = null
await teamMemberService.value.create({
teamId: teamId.value,
username: newMember.value!.username,
} as unknown as ITeamMember)
newMember.value = null
await loadTeam()
success({message: t('team.edit.userAddedSuccess')})
}
@@ -393,16 +394,16 @@ async function findUser(query: string) {
return
}
const users = await userService.value.getAll({}, {s: query})
foundUsers.value = users.filter((u: IUser) => u.id !== userInfo.value.id)
const users = await userService.value.getAll(new UserModel(), {s: query})
foundUsers.value = users.filter((u: IUser) => u.id !== userInfo.value?.id)
}
async function leave() {
try {
await teamMemberService.value.delete({
teamId: teamId.value,
username: userInfo.value.username,
})
await teamMemberService.value.delete({
teamId: teamId.value,
username: userInfo.value?.username ?? '',
} as unknown as ITeamMember)
success({message: t('team.edit.leave.success')})
await router.push({name: 'home'})
} finally {

View File

@@ -41,6 +41,7 @@
<script setup lang="ts">
import {ref, shallowReactive} from 'vue'
import type { ITeam } from '@/modelTypes/ITeam'
import { useI18n } from 'vue-i18n'
import TeamService from '@/services/team'
@@ -49,7 +50,7 @@ import { useTitle } from '@/composables/useTitle'
const { t } = useI18n({useScope: 'global'})
useTitle(() => t('team.title'))
const teams = ref([])
const teams = ref<ITeam[]>([])
const teamService = shallowReactive(new TeamService())
teamService.getAll().then((result) => {
teams.value = result

View File

@@ -208,16 +208,23 @@ const validateUsernameField = useDebounceFn(() => {
const needsTotpPasscode = computed(() => authStore.needsTotpPasscode)
const totpPasscode = ref<HTMLInputElement | null>(null)
interface Credentials {
username: string | undefined
password: string
longToken: boolean
totpPasscode?: string
}
async function submit() {
errorMessage.value = ''
errorMessage.value = ''
// Some browsers prevent Vue bindings from working with autofilled values.
// To work around this, we're manually getting the values here instead of relying on vue bindings.
// For more info, see https://kolaente.dev/vikunja/frontend/issues/78
const credentials = {
username: usernameRef.value?.value,
password: password.value,
longToken: rememberMe.value,
}
const credentials: Credentials = {
username: usernameRef.value?.value,
password: password.value,
longToken: rememberMe.value,
}
if (credentials.username === '' || credentials.password === '') {
// Trigger the validation error messages

View File

@@ -30,10 +30,11 @@
class="label"
for="password"
>{{ $t('user.auth.password') }}</label>
<Password
@submit="resetPassword"
@update:modelValue="v => credentials.password = v"
/>
<Password
:model-value="credentials.password"
@submit="resetPassword"
@update:modelValue="v => credentials.password = v"
/>
</div>
<div class="field is-grouped">
@@ -85,9 +86,9 @@ async function resetPassword() {
}
const passwordReset = new PasswordResetModel({newPassword: credentials.password, token: token})
try {
const {message} = await passwordResetService.resetPassword(passwordReset)
successMessage.value = message
try {
const {message} = await passwordResetService.resetPassword(passwordReset) as unknown as {message: string}
successMessage.value = message
} catch (e) {
errorMsg.value = e.response.data.message
}

View File

@@ -72,11 +72,12 @@
class="label"
for="password"
>{{ $t('user.auth.password') }}</label>
<Password
:validate-initially="validatePasswordInitially"
@submit="submit"
@update:modelValue="v => credentials.password = v"
/>
<Password
:model-value="credentials.password"
:validate-initially="validatePasswordInitially"
@submit="submit"
@update:modelValue="v => credentials.password = v"
/>
</div>
<XButton
@@ -199,8 +200,9 @@ async function submit() {
try {
await authStore.register(toRaw(credentials))
} catch (e) {
errorMessage.value = e?.message
}
} catch (e: unknown) {
const err = e as {message?: string}
errorMessage.value = err.message ?? ''
}
}
</script>

View File

@@ -79,9 +79,10 @@ async function requestPasswordReset() {
try {
await passwordResetService.requestResetPassword(passwordReset.value)
isSuccess.value = true
} catch (e) {
errorMsg.value = e.response.data.message
}
} catch (e: unknown) {
const err = e as {response?: {data?: {message?: string}}}
errorMsg.value = err.response?.data?.message ?? ''
}
}
</script>

View File

@@ -18,19 +18,19 @@ const service = new ApiTokenService()
const tokens = ref<IApiToken[]>([])
const apiDocsUrl = window.API_URL + '/docs'
const showCreateForm = ref(false)
const availableRoutes = ref(null)
const availableRoutes = ref<Record<string, Record<string, string[]>> | null>(null)
const newToken = ref<IApiToken>(new ApiTokenModel())
const newTokenExpiry = ref<string | number>(30)
const newTokenExpiryCustom = ref(new Date())
const newTokenPermissions = ref({})
const newTokenPermissionsGroup = ref({})
const newTokenPermissions = ref<Record<string, Record<string, boolean>>>({})
const newTokenPermissionsGroup = ref<Record<string, boolean>>({})
const newTokenTitleValid = ref(true)
const newTokenPermissionValid = ref(true)
const apiTokenTitle = ref()
const tokenCreatedSuccessMessage = ref('')
const showDeleteModal = ref<boolean>(false)
const tokenToDelete = ref<IApiToken>()
const tokenToDelete = ref<IApiToken | null>(null)
const {t} = useI18n()
@@ -50,7 +50,7 @@ onMounted(async () => {
tokens.value = await service.getAll()
const allRoutes = await service.getAvailableRoutes()
const routesAvailable = {}
const routesAvailable: Record<string, Record<string, string[]>> = {}
const keys = Object.keys(allRoutes)
keys.sort((a, b) => (a === 'other' ? 1 : b === 'other' ? -1 : 0))
keys.forEach(key => {
@@ -63,8 +63,8 @@ onMounted(async () => {
})
function resetPermissions() {
newTokenPermissions.value = {}
Object.entries(availableRoutes.value).forEach(entry => {
newTokenPermissions.value = {}
Object.entries(availableRoutes.value || {}).forEach(entry => {
const [group, routes] = entry
newTokenPermissions.value[group] = {}
Object.keys(routes).forEach(r => {
@@ -74,13 +74,16 @@ function resetPermissions() {
}
async function deleteToken() {
await service.delete(tokenToDelete.value)
showDeleteModal.value = false
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value.id)
tokenToDelete.value = null
if (index === -1) {
return
}
if (!tokenToDelete.value) {
return
}
await service.delete(tokenToDelete.value!)
showDeleteModal.value = false
const index = tokens.value.findIndex(el => el.id === tokenToDelete.value!.id)
tokenToDelete.value = null
if (index === -1) {
return
}
tokens.value.splice(index, 1)
}
@@ -127,22 +130,22 @@ async function createToken() {
showCreateForm.value = false
}
function formatPermissionTitle(title: string): string {
return title.replaceAll('_', ' ')
function formatPermissionTitle(title: string | number): string {
return String(title).split('_').join(' ')
}
function selectPermissionGroup(group: string, checked: boolean) {
Object.entries(availableRoutes.value[group]).forEach(entry => {
function selectPermissionGroup(group: string | number, checked: boolean) {
Object.entries(availableRoutes.value?.[group] || {}).forEach(entry => {
const [key] = entry
newTokenPermissions.value[group][key] = checked
})
}
function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
if (checked) {
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
let allChecked = true
Object.entries(availableRoutes.value[group]).forEach(entry => {
function toggleGroupPermissionsFromChild(group: string | number, checked: boolean) {
if (checked) {
// Check if all permissions of that group are checked and check the "select all" checkbox in that case
let allChecked = true
Object.entries(availableRoutes.value?.[group] || {}).forEach(entry => {
const [key] = entry
if (!newTokenPermissions.value[group][key]) {
allChecked = false
@@ -368,7 +371,7 @@ function toggleGroupPermissionsFromChild(group: string, checked: boolean) {
<template #text>
<p>
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete.title}) }}<br>
{{ $t('user.settings.apiTokens.delete.text1', {token: tokenToDelete?.title}) }}<br>
{{ $t('user.settings.apiTokens.delete.text2') }}
</p>
</template>

View File

@@ -33,7 +33,7 @@
<XButton
v-if="!isCropAvatar"
:loading="avatarService.loading || loading"
@click="avatarUploadInput.click()"
@click="avatarUploadInput?.click()"
>
{{ $t('user.settings.avatar.uploadAvatar') }}
</XButton>
@@ -87,6 +87,7 @@ import {useTitle} from '@/composables/useTitle'
import {success} from '@/message'
import {useAuthStore} from '@/stores/auth'
import Message from '@/components/misc/Message.vue'
import type { AvatarProvider } from '@/modelTypes/IAvatar'
const {t} = useI18n({useScope: 'global'})
const authStore = useAuthStore()
@@ -106,11 +107,11 @@ const avatarService = shallowReactive(new AvatarService())
const loading = ref(false)
const avatarProvider = ref('')
const avatarProvider = ref<AvatarProvider>('default')
async function avatarStatus() {
const {avatarProvider: currentProvider} = await avatarService.get({})
avatarProvider.value = currentProvider
const {avatarProvider: currentProvider} = await avatarService.get(new AvatarModel({}))
avatarProvider.value = currentProvider
}
avatarStatus()
@@ -134,9 +135,11 @@ async function uploadAvatar() {
return
}
try {
const blob = await new Promise(resolve => canvas.toBlob(blob => resolve(blob)))
await avatarService.create(blob)
try {
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob((b: Blob | null) => resolve(b)))
if (blob) {
await avatarService.create(blob)
}
success({message: t('user.settings.avatar.setSuccess')})
authStore.reloadAvatar()
} finally {
@@ -145,24 +148,26 @@ async function uploadAvatar() {
}
}
const avatarToCrop = ref()
const avatarUploadInput = ref()
const avatarToCrop = ref<string | ArrayBuffer | null>(null)
const avatarUploadInput = ref<HTMLInputElement | null>(null)
function cropAvatar() {
const avatar = avatarUploadInput.value.files
const files = avatarUploadInput.value?.files
if (avatar.length === 0) {
return
}
if (!files || files.length === 0) {
return
}
loading.value = true
const reader = new FileReader()
reader.onload = e => {
avatarToCrop.value = e.target.result
isCropAvatar.value = true
}
const reader = new FileReader()
reader.onload = e => {
const target = e.target as FileReader | null
if (!target) return
avatarToCrop.value = target.result
isCropAvatar.value = true
}
reader.onloadend = () => loading.value = false
reader.readAsDataURL(avatar[0])
reader.readAsDataURL(files[0])
}
</script>

View File

@@ -109,6 +109,7 @@ 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'
import { formatDateShort } from '@/helpers/time/formatDate'
import type {ICaldavToken} from '@/modelTypes/ICaldavToken'
import {useConfigStore} from '@/stores/config'
@@ -128,8 +129,10 @@ service.getAll().then((result: ICaldavToken[]) => {
const newToken = ref<ICaldavToken>()
async function createToken() {
newToken.value = await service.create({}) as ICaldavToken
tokens.value.push(newToken.value)
// The API does not require any payload when creating a token.
// Create an empty model instance to satisfy the expected type.
newToken.value = await service.create(new CaldavTokenModel({}))
tokens.value.push(newToken.value)
}
async function deleteToken(token: ICaldavToken) {

View File

@@ -26,7 +26,7 @@
v-if="isExternalUser"
class="help"
>
{{ $t('user.settings.general.externalUserNameChange', {provider: authStore.info.authProvider}) }}
{{ $t('user.settings.general.externalUserNameChange', {provider: authStore.info?.authProvider}) }}
</p>
</div>
<div class="field">
@@ -252,7 +252,7 @@ export default {name: 'UserSettingsGeneral'}
</script>
<script setup lang="ts">
import {computed, watch, ref} from 'vue'
import {computed, watch, ref, type Ref} from 'vue'
import {useI18n} from 'vue-i18n'
import {PrefixMode} from '@/modules/parseTaskText'
@@ -271,6 +271,8 @@ import {useAuthStore} from '@/stores/auth'
import type {IUserSettings} from '@/modelTypes/IUserSettings'
import {isSavedFilter} from '@/services/savedFilter'
import {DEFAULT_PROJECT_VIEW_SETTINGS} from '@/modelTypes/IProjectView'
import type {IProject} from '@/modelTypes/IProject'
import ProjectModel from '@/models/project'
import {PRIORITIES} from '@/constants/priorities'
const {t} = useI18n({useScope: 'global'})
@@ -309,9 +311,9 @@ function useAvailableTimezones(settingsRef: Ref<IUserSettings>) {
.then(r => {
if (r.data) {
// Transform timezones into objects with value/label pairs
availableTimezones.value = r.data
.sort((a, b) => a.localeCompare(b))
.map((tz: string) => ({
availableTimezones.value = r.data
.sort((a: string, b: string) => a.localeCompare(b))
.map((tz: string) => ({
value: tz,
label: tz.replace(/_/g, ' '),
}))
@@ -364,12 +366,12 @@ const {
const id = ref(createRandomID())
const availableLanguageOptions = ref(
Object.entries(SUPPORTED_LOCALES)
.map(l => ({code: l[0], title: l[1]}))
.sort((a, b) => a.title.localeCompare(b.title)),
Object.entries(SUPPORTED_LOCALES)
.map(l => ({code: l[0], title: l[1]}))
.sort((a: {code: string; title: string}, b: {code: string; title: string}) => a.title.localeCompare(b.title)),
)
const isExternalUser = computed(() => !authStore.info.isLocalUser)
const isExternalUser = computed(() => !authStore.info?.isLocalUser)
watch(
() => authStore.settings,
@@ -384,25 +386,30 @@ watch(
)
const projectStore = useProjectStore()
const defaultProject = computed({
get: () => projectStore.projects[settings.value.defaultProjectId],
set(l) {
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
},
const defaultProject = computed<IProject>({
get: () => settings.value.defaultProjectId !== undefined
? (projectStore.projects[settings.value.defaultProjectId] as IProject) ?? new ProjectModel()
: new ProjectModel(),
set(l: IProject) {
settings.value.defaultProjectId = l ? l.id : DEFAULT_PROJECT_ID
},
})
const filterUsedInOverview = computed({
get: () => projectStore.projects[settings.value.frontendSettings.filterIdUsedOnOverview],
set(l) {
settings.value.frontendSettings.filterIdUsedOnOverview = l ? l.id : null
},
const filterUsedInOverview = computed<IProject>({
get: () => settings.value.frontendSettings.filterIdUsedOnOverview !== null
? (projectStore.projects[settings.value.frontendSettings.filterIdUsedOnOverview] as IProject) ?? new ProjectModel()
: new ProjectModel(),
set(l: IProject) {
settings.value.frontendSettings.filterIdUsedOnOverview = l ? l.id : null
},
})
const hasFilters = computed(() => typeof projectStore.projectsArray.find(p => isSavedFilter(p)) !== 'undefined')
const hasFilters = computed(() => typeof projectStore.projectsArray.find(p => isSavedFilter(p as IProject)) !== 'undefined')
const loading = computed(() => authStore.isLoadingGeneralSettings)
async function updateSettings() {
await authStore.saveUserSettings({
settings: {...settings.value},
})
await authStore.saveUserSettings({
settings: {...settings.value},
showMessage: true,
})
}
</script>

View File

@@ -135,7 +135,7 @@ async function totpStatus() {
return
}
try {
totp.value = await totpService.get({})
totp.value = await totpService.get(new TotpModel())
totpSetQrCode()
} catch(e: unknown) {
// Error code 1016 means totp is not enabled, we don't need an error in that case.

View File

@@ -20,7 +20,8 @@
"strictNullChecks": true,
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"vite-plugin-sentry/client": ["./node_modules/vite-plugin-sentry/client.d.ts"]
},
"types": [
// https://github.com/ikenfin/vite-plugin-sentry#typescript

View File

@@ -1,5 +1,5 @@
/// <reference types="vitest" />
import {defineConfig, type PluginOption, loadEnv} from 'vite'
import {defineConfig, type PluginOption, loadEnv, type ImportMetaEnv} from 'vite'
import {configDefaults} from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import {URL, fileURLToPath} from 'node:url'