From f5434b3b31377cc01b7d4416180e1b8e0b175afc Mon Sep 17 00:00:00 2001 From: Nils Wisiol Date: Sat, 10 Jan 2026 12:00:28 +0100 Subject: [PATCH] chore(webapp): migration to vuetify 3 AI-assisted changes. I played with everything I could think of on the website: direct debit donation account creation, login, 2fa enabled, change email, delete account domain list, filtering rrset creation and deletion scrolled through all pages of the web site --- www/webapp/.gitignore | 1 + www/webapp/package.json | 23 +- www/webapp/src/App.vue | 224 ++++++++++-------- .../ActivateAccountActionHandler.vue | 11 +- ...eAccountWithOverrideTokenActionHandler.vue | 20 +- .../components/CreateTOTPActionHandler.vue | 7 +- .../src/components/DonateDirectDebitForm.vue | 27 ++- www/webapp/src/components/ErrorAlert.vue | 2 +- .../src/components/Field/GenericCaptcha.vue | 20 +- .../src/components/Field/GenericCheckbox.vue | 20 +- .../src/components/Field/GenericSwitchbox.vue | 20 +- .../src/components/Field/GenericText.vue | 39 ++- .../src/components/Field/GenericTextarea.vue | 27 ++- www/webapp/src/components/Field/RRSetType.vue | 16 +- www/webapp/src/components/Field/RecordA.vue | 3 +- .../src/components/Field/RecordAAAA.vue | 3 +- www/webapp/src/components/Field/RecordCAA.vue | 3 +- .../src/components/Field/RecordCNAME.vue | 15 +- .../src/components/Field/RecordDNSKEY.vue | 3 +- www/webapp/src/components/Field/RecordDS.vue | 3 +- .../src/components/Field/RecordItem.vue | 124 +++++++--- .../src/components/Field/RecordList.vue | 40 +++- www/webapp/src/components/Field/RecordMX.vue | 15 +- www/webapp/src/components/Field/RecordNS.vue | 15 +- .../src/components/Field/RecordOPENPGPKEY.vue | 3 +- www/webapp/src/components/Field/RecordSRV.vue | 15 +- .../src/components/Field/RecordSVCB.vue | 15 +- .../src/components/Field/RecordSubnet.vue | 3 +- .../src/components/Field/RecordTLSA.vue | 3 +- www/webapp/src/components/Field/RecordTXT.vue | 10 +- www/webapp/src/components/Field/TTL.vue | 27 ++- www/webapp/src/components/Field/TimeAgo.vue | 11 +- www/webapp/src/components/QrcodeVue.vue | 61 +++++ .../components/ResetPasswordActionHandler.vue | 6 +- www/webapp/src/main.js | 25 +- www/webapp/src/plugins/vuetify.js | 33 ++- www/webapp/src/router/index.js | 74 +++--- www/webapp/src/views/AboutPage.vue | 2 +- www/webapp/src/views/ChangeEmail.vue | 20 +- www/webapp/src/views/ConfirmationPage.vue | 8 +- .../src/views/Console/DomainSetupDialog.vue | 17 +- .../src/views/Console/TOTPVerifyDialog.vue | 71 ++++-- www/webapp/src/views/CrudList.vue | 188 ++++++++++----- www/webapp/src/views/CrudListToken.vue | 2 +- www/webapp/src/views/DeleteAccount.vue | 16 +- www/webapp/src/views/DomainSetup.vue | 48 ++-- www/webapp/src/views/DomainSetupPage.vue | 5 +- www/webapp/src/views/DonatePage.vue | 110 +++++---- www/webapp/src/views/DynSetup.vue | 31 ++- www/webapp/src/views/HomePage.vue | 74 +++--- www/webapp/src/views/LoginPage.vue | 17 +- www/webapp/src/views/MFA.vue | 11 +- www/webapp/src/views/PrivacyPolicy.vue | 2 +- www/webapp/src/views/ResetPassword.vue | 10 +- www/webapp/src/views/SignUp.vue | 34 ++- www/webapp/src/views/TermsPage.vue | 2 +- www/webapp/src/views/WelcomePage.vue | 5 +- www/webapp/vite.config.js | 20 +- 58 files changed, 1024 insertions(+), 636 deletions(-) create mode 100644 www/webapp/src/components/QrcodeVue.vue diff --git a/www/webapp/.gitignore b/www/webapp/.gitignore index 46955aa0f..7e07b0286 100644 --- a/www/webapp/.gitignore +++ b/www/webapp/.gitignore @@ -1,5 +1,6 @@ .DS_Store node_modules +.vite /tests/e2e/videos/ /tests/e2e/screenshots/ diff --git a/www/webapp/package.json b/www/webapp/package.json index 11aa2b0d1..5585bc742 100644 --- a/www/webapp/package.json +++ b/www/webapp/package.json @@ -7,7 +7,8 @@ "serve": "vite preview", "build": "vite build", "lint": "eslint --ignore-path .gitignore --no-fix src/**/*.{vue,js,json}", - "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}" + "lint:fix": "eslint --ignore-path .gitignore --fix src/**/*.{vue,js,json}", + "postinstall": "vue-demi-switch 3" }, "type": "module", "engines": { @@ -16,26 +17,30 @@ "dependencies": { "@fontsource/roboto": "^5.0.3", "@mdi/js": "~7.4.47", + "@vuelidate/core": "^2.0.3", + "@vuelidate/validators": "^2.0.4", "axios": "^1.4.0", "date-fns": "^4.1.0", "pinia": "^2.0.30", - "vue": "~2.7.14", - "vue-router": "~3.6.5", - "vuelidate": "^0.7.7", - "vuetify": "^2.7.0" + "vue": "^3.4.19", + "vue-router": "^4.3.0", + "vuetify": "^3.7.5" }, "devDependencies": { "@vitejs/plugin-legacy": "^6.0.0", - "@vitejs/plugin-vue2": "^2.3.3", + "@vitejs/plugin-vue": "^5.2.0", "eslint": "^8.45.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.27.5", "eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue-scoped-css": "^2.6.1", - "eslint-plugin-vuetify": "^1.1.0", + "eslint-plugin-vuetify": "^2.1.1", "sass": "~1.83.4", - "unplugin-vue-components": "^28.0.0", "vite": "^6.0.11", - "vuetify-loader": "~1.9.1" + "vite-plugin-vuetify": "^2.0.4", + "vue-demi": "^0.14.10" + }, + "overrides": { + "vue-demi": "^0.14.10" } } diff --git a/www/webapp/src/App.vue b/www/webapp/src/App.vue index 4499155f6..e04680950 100644 --- a/www/webapp/src/App.vue +++ b/www/webapp/src/App.vue @@ -1,90 +1,91 @@ @@ -50,6 +49,10 @@ export default { type: Array, default: () => [], }, + modelValue: { + type: [String, Number], + required: false, + }, value: { type: [String, Number], required: false, @@ -59,9 +62,19 @@ export default { required: false, }, }, + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { - changed(event, e) { - this.$emit(event, e); + updateValue(value) { + this.$emit('update:modelValue', value); + this.$emit('input', value); + this.$emit('dirty'); + }, + handleKeyup(event) { + this.$emit('keyup', event); this.$emit('dirty'); }, }, diff --git a/www/webapp/src/components/Field/RRSetType.vue b/www/webapp/src/components/Field/RRSetType.vue index ed63b1a35..d6f172857 100644 --- a/www/webapp/src/components/Field/RRSetType.vue +++ b/www/webapp/src/components/Field/RRSetType.vue @@ -5,11 +5,11 @@ :error-messages="errorMessages" hint="You can also enter other types. For a full list, check the documentation." :persistent-hint="!readonly" - :value="value" + :model-value="inputValue" :items="types" :required="required" :rules="[v => !required || !!v || 'Required.']" - @input="input($event)" + @update:modelValue="input" /> @@ -37,9 +37,13 @@ export default { type: Boolean, default: false, }, + modelValue: { + type: String, + required: false, + }, value: { type: String, - required: true, + required: false, }, }, data: () => ({ @@ -60,8 +64,14 @@ export default { 'DS', ], }), + computed: { + inputValue() { + return this.modelValue ?? this.value; + }, + }, methods: { input(event) { + this.$emit('update:modelValue', event); this.$emit('input', event); }, }, diff --git a/www/webapp/src/components/Field/RecordA.vue b/www/webapp/src/components/Field/RecordA.vue index 868ccc184..20e8ce2be 100644 --- a/www/webapp/src/components/Field/RecordA.vue +++ b/www/webapp/src/components/Field/RecordA.vue @@ -1,10 +1,11 @@ diff --git a/www/webapp/src/components/ResetPasswordActionHandler.vue b/www/webapp/src/components/ResetPasswordActionHandler.vue index f5b29363a..577b27b4e 100644 --- a/www/webapp/src/components/ResetPasswordActionHandler.vue +++ b/www/webapp/src/components/ResetPasswordActionHandler.vue @@ -3,7 +3,7 @@
h(App) -}).$mount('#app') +app.use(pinia) +app.use(router) +app.use(vuetify) + +app.mount('#app') diff --git a/www/webapp/src/plugins/vuetify.js b/www/webapp/src/plugins/vuetify.js index b616aac30..7f38e5e85 100644 --- a/www/webapp/src/plugins/vuetify.js +++ b/www/webapp/src/plugins/vuetify.js @@ -1,21 +1,30 @@ -import Vue from 'vue'; -import Vuetify from 'vuetify/lib'; -import colors from 'vuetify/lib/util/colors' +import 'vuetify/styles' +import { createVuetify } from 'vuetify' +import { aliases, mdi } from 'vuetify/iconsets/mdi-svg' +import { VDataTable, VOtpInput } from 'vuetify/components' +import colors from 'vuetify/util/colors' - -Vue.use(Vuetify); - - -export default new Vuetify({ +export default createVuetify({ + components: { + VDataTable, + VOtpInput, + }, icons: { - iconfont: 'mdiSvg', // 'mdi' || 'mdiSvg' || 'md' || 'fa' || 'fa4' || 'faSvg' + defaultSet: 'mdi', + aliases, + sets: { + mdi, + }, }, theme: { + defaultTheme: 'light', themes: { light: { - primary: colors.amber, - secondary: colors.lightBlue.darken1, - accent: colors.amber.accent4, + colors: { + primary: colors.amber.base, + secondary: colors.lightBlue.darken1, + accent: colors.amber.accent4, + }, }, }, }, diff --git a/www/webapp/src/router/index.js b/www/webapp/src/router/index.js index 123fb6429..681a3d846 100644 --- a/www/webapp/src/router/index.js +++ b/www/webapp/src/router/index.js @@ -1,8 +1,20 @@ -import VueRouter from 'vue-router' +import { createRouter, createWebHistory } from 'vue-router' import HomePage from '@/views/HomePage.vue' import {HTTP} from '@/utils'; import {useUserStore} from "@/store/user"; +const lazy = (loader) => () => loader().catch((error) => { + const message = error?.message || ''; + if ( + message.includes('Failed to fetch dynamically imported module') || + message.includes('Importing a module script failed') || + message.includes('error loading dynamically imported module') + ) { + window.location.reload(); + } + throw error; +}); + const routes = [ { path: '/', @@ -12,136 +24,132 @@ const routes = [ { path: '/signup/:email?', name: 'signup', - // route level code-splitting - // this generates a separate chunk (about.[hash].js) for this route - // which is lazy-loaded when the route is visited. - component: () => import('@/views/SignUp.vue'), + component: lazy(() => import('@/views/SignUp.vue')), }, { path: '/custom-setup/:domain', name: 'customSetup', - component: () => import('@/views/DomainSetupPage.vue'), + component: lazy(() => import('@/views/DomainSetupPage.vue')), props: true, }, { path: '/dyn-setup/:domain', alias: '/dynsetup/:domain', name: 'dynSetup', - component: () => import('@/views/DynSetup.vue'), + component: lazy(() => import('@/views/DynSetup.vue')), }, { path: '/welcome/:domain?', name: 'welcome', - component: () => import('@/views/WelcomePage.vue'), + component: lazy(() => import('@/views/WelcomePage.vue')), }, { - path: 'https://desec.readthedocs.io/', + path: '/docs', name: 'docs', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://desec.readthedocs.io/' }, }, { - path: 'https://talk.desec.io/', + path: '/talk', name: 'talk', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://talk.desec.io/' }, }, { path: '/confirm/:action/:code', name: 'confirmation', - component: () => import('@/views/ConfirmationPage.vue') + component: lazy(() => import('@/views/ConfirmationPage.vue')) }, { path: '/reset-password/:email?', name: 'reset-password', - component: () => import('@/views/ResetPassword.vue'), + component: lazy(() => import('@/views/ResetPassword.vue')), }, { path: '/totp/', name: 'totp', - component: () => import('@/views/CrudListTOTP.vue'), + component: lazy(() => import('@/views/CrudListTOTP.vue')), meta: {guest: false}, }, { path: '/totp-verify/', name: 'TOTPVerify', - component: () => import('@/views/Console/TOTPVerifyDialog.vue'), + component: lazy(() => import('@/views/Console/TOTPVerifyDialog.vue')), props: (route) => ({...route.params}), }, { path: '/mfa/', name: 'mfa', - component: () => import('@/views/MFA.vue'), + component: lazy(() => import('@/views/MFA.vue')), meta: {guest: false}, }, { path: '/change-email/:email?', name: 'change-email', - component: () => import('@/views/ChangeEmail.vue'), + component: lazy(() => import('@/views/ChangeEmail.vue')), meta: {guest: false}, }, { path: '/delete-account/', name: 'delete-account', - component: () => import('@/views/DeleteAccount.vue'), + component: lazy(() => import('@/views/DeleteAccount.vue')), meta: {guest: false}, }, { path: '/donate/', name: 'donate', - component: () => import('@/views/DonatePage.vue'), + component: lazy(() => import('@/views/DonatePage.vue')), }, { - path: 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open', + path: '/roadmap', name: 'roadmap', - beforeEnter(to) { location.href = to.path }, + beforeEnter() { location.href = 'https://github.com/desec-io/desec-stack/milestones?direction=asc&sort=title&state=open' }, }, { path: '/impressum/', name: 'impressum', - component: () => import('@/views/ImpressumPage.vue'), + component: lazy(() => import('@/views/ImpressumPage.vue')), }, { path: '/privacy-policy/', name: 'privacy-policy', - component: () => import('@/views/PrivacyPolicy.vue'), + component: lazy(() => import('@/views/PrivacyPolicy.vue')), }, { path: '/terms/', name: 'terms', - component: () => import('@/views/TermsPage.vue'), + component: lazy(() => import('@/views/TermsPage.vue')), }, { path: '/about/', name: 'about', - component: () => import('@/views/AboutPage.vue'), + component: lazy(() => import('@/views/AboutPage.vue')), }, { path: '/login', name: 'login', - component: () => import('@/views/LoginPage.vue'), + component: lazy(() => import('@/views/LoginPage.vue')), }, { path: '/tokens', name: 'tokens', - component: () => import('@/views/CrudListToken.vue'), + component: lazy(() => import('@/views/CrudListToken.vue')), meta: {guest: false}, }, { path: '/domains', name: 'domains', - component: () => import('@/views/CrudListDomain.vue'), + component: lazy(() => import('@/views/CrudListDomain.vue')), meta: {guest: false}, }, { path: '/domains/:domain', name: 'domain', - component: () => import('@/views/CrudListRecord.vue'), + component: lazy(() => import('@/views/CrudListRecord.vue')), meta: {guest: false}, }, ] -const router = new VueRouter({ - mode: 'history', - base: import.meta.env.BASE_URL, +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), scrollBehavior (to, from) { // Skip if destination full path has query parameters and differs in no other way from previous if (from && Object.keys(to.query).length) { diff --git a/www/webapp/src/views/AboutPage.vue b/www/webapp/src/views/AboutPage.vue index dbf24088e..62bb9fb2c 100644 --- a/www/webapp/src/views/AboutPage.vue +++ b/www/webapp/src/views/AboutPage.vue @@ -26,7 +26,7 @@ - + diff --git a/www/webapp/src/views/ChangeEmail.vue b/www/webapp/src/views/ChangeEmail.vue index b275b2dc1..d6d7f018c 100644 --- a/www/webapp/src/views/ChangeEmail.vue +++ b/www/webapp/src/views/ChangeEmail.vue @@ -20,7 +20,6 @@ Change Account Email Address @@ -38,32 +37,30 @@ {{ actionName }} Confirmation @@ -41,7 +40,7 @@ If you like our service, please consider donating.

- Donate + Donate

@@ -127,11 +126,6 @@ this.errors.splice(0, this.errors.length); } }, - filters: { - replace: function (value, a, b) { - return value.replace(a, b) - } - }, }; diff --git a/www/webapp/src/views/Console/DomainSetupDialog.vue b/www/webapp/src/views/Console/DomainSetupDialog.vue index c112777df..f491a0acc 100644 --- a/www/webapp/src/views/Console/DomainSetupDialog.vue +++ b/www/webapp/src/views/Console/DomainSetupDialog.vue @@ -12,15 +12,13 @@ Setup Instructions for {{ domain }}
- - {{ mdiClose }} - + Your domain {{ domain }} has been successfully created! @@ -49,20 +47,21 @@ export default { type: Boolean, default: false, }, - }, - data: () => ({ - mdiClose, - value: { + modelValue: { type: Boolean, default: true, }, + }, + data: () => ({ + mdiClose, }), computed: { show: { get() { - return this.value + return this.modelValue }, set(value) { + this.$emit('update:modelValue', value) this.$emit('input', value) } } diff --git a/www/webapp/src/views/Console/TOTPVerifyDialog.vue b/www/webapp/src/views/Console/TOTPVerifyDialog.vue index 4290b6b76..3cb202055 100644 --- a/www/webapp/src/views/Console/TOTPVerifyDialog.vue +++ b/www/webapp/src/views/Console/TOTPVerifyDialog.vue @@ -8,43 +8,48 @@ > - -
- Verify TOTP: {{ name }} -
+ + + Verify TOTP: {{ displayName }} + - - {{ mdiClose }} - + + + - + {{ detail }} - + {{ successDetail }}

- {{ mdiCheck }} + Great! Continue to log in.

- +

- {{ mdiNumeric1Circle }} + Please scan the following QR code with an authenticator app (e.g. Google Authenticator).
This code is only displayed once.

- +

- {{ mdiNumeric2Circle }} + Enter the code displayed in the authenticator app to confirm and activate the token:

@@ -65,7 +70,7 @@ Want to know what's in the code? — It's your TOTP secret:
- {{ data.secret }}
+ {{ payload.secret }}

@@ -92,7 +97,7 @@