diff --git a/.eslintrc.js b/.eslintrc.js
index 40fa92d1e8c..23dc753f8b9 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -35,6 +35,9 @@ module.exports = {
 		jsdoc: {
 			mode: 'typescript',
 		},
+		'import/resolver': {
+			typescript: {}, // this loads <rootdir>/tsconfig.json to eslint
+		},
 	},
 	overrides: [
 		// Allow any in tests
@@ -43,6 +46,6 @@ module.exports = {
 			rules: {
 				'@typescript-eslint/no-explicit-any': 'warn',
 			},
-		}
+		},
 	],
 }
diff --git a/cypress/dockerNode.ts b/cypress/dockerNode.ts
index 5da0ae96ad7..b65f164dc15 100644
--- a/cypress/dockerNode.ts
+++ b/cypress/dockerNode.ts
@@ -88,6 +88,12 @@ export const startNextcloud = async function(branch: string = getCurrentGitBranc
 					Type: 'tmpfs',
 					ReadOnly: false,
 				}],
+				PortBindings: {
+					'80/tcp': [{
+						HostIP: '0.0.0.0',
+						HostPort: '8083',
+					}],
+				},
 			},
 			Env: [
 				`BRANCH=${branch}`,
@@ -242,11 +248,15 @@ export const getContainerIP = async function(
 	while (ip === '' && tries < 10) {
 		tries++
 
-		await container.inspect(function(err, data) {
+		container.inspect(function(err, data) {
 			if (err) {
 				throw err
 			}
-			ip = data?.NetworkSettings?.IPAddress || ''
+			if (data?.HostConfig.PortBindings?.['80/tcp']?.[0]?.HostPort) {
+				ip = `localhost:${data.HostConfig.PortBindings['80/tcp'][0].HostPort}`
+			} else {
+				ip = data?.NetworkSettings?.IPAddress || ''
+			}
 		})
 
 		if (ip !== '') {
diff --git a/cypress/e2e/files/LivePhotosUtils.ts b/cypress/e2e/files/LivePhotosUtils.ts
index 9b4f1dbbf3f..34e6a1d934e 100644
--- a/cypress/e2e/files/LivePhotosUtils.ts
+++ b/cypress/e2e/files/LivePhotosUtils.ts
@@ -14,34 +14,25 @@ type SetupInfo = {
 }
 
 /**
- *
- * @param user
- * @param fileName
- * @param domain
- * @param requesttoken
- * @param metadata
  */
 function setMetadata(user: User, fileName: string, requesttoken: string, metadata: object) {
-	cy.url().then(url => {
-		const hostname = new URL(url).hostname
-		cy.request({
-			method: 'PROPPATCH',
-			url: `http://${hostname}/remote.php/dav/files/${user.userId}/${fileName}`,
-			auth: { user: user.userId, pass: user.password },
-			headers: {
-				requesttoken,
-			},
-			body: `<?xml version="1.0"?>
-				<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
-					<d:set>
-						<d:prop>
-							${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
-						</d:prop>
-					</d:set>
-				</d:propertyupdate>`,
-		})
+	const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?/, '')
+	cy.request({
+		method: 'PROPPATCH',
+		url: `${base}/remote.php/dav/files/${user.userId}/${fileName}`,
+		auth: { user: user.userId, pass: user.password },
+		headers: {
+			requesttoken,
+		},
+		body: `<?xml version="1.0"?>
+			<d:propertyupdate xmlns:d="DAV:" xmlns:nc="http://nextcloud.org/ns">
+				<d:set>
+					<d:prop>
+						${Object.entries(metadata).map(([key, value]) => `<${key}>${value}</${key}>`).join('\n')}
+					</d:prop>
+				</d:set>
+			</d:propertyupdate>`,
 	})
-
 }
 
 /**
diff --git a/cypress/e2e/files_external/files-user-credentials.cy.ts b/cypress/e2e/files_external/files-user-credentials.cy.ts
index 1911c5477c3..a0cd805312c 100644
--- a/cypress/e2e/files_external/files-user-credentials.cy.ts
+++ b/cypress/e2e/files_external/files-user-credentials.cy.ts
@@ -15,9 +15,6 @@ describe('Files user credentials', { testIsolation: true }, () => {
 	let user2: User
 	let storageUser: User
 
-	beforeEach(() => {
-	})
-
 	before(() => {
 		cy.runOccCommand('app:enable files_external')
 
@@ -43,8 +40,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
 	})
 
 	it('Create a user storage with user credentials', () => {
-		const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
-		createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host: url.replace('index.php/', ''), secure: 'false' })
+		// Its not the public server address but the address so the server itself can connect to it
+		const base = 'http://localhost'
+		const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+		createStorageWithConfig(storageUser.userId, StorageBackend.DAV, AuthBackend.UserProvided, { host, secure: 'false' })
 
 		cy.login(user1)
 		cy.visit('/apps/files/extstoragemounts')
@@ -72,6 +71,7 @@ describe('Files user credentials', { testIsolation: true }, () => {
 
 		// Auth dialog should be closed and the set credentials button should be gone
 		cy.get('@authDialog').should('not.exist', { timeout: 2000 })
+
 		getActionEntryForFile(storageUser.userId, ACTION_CREDENTIALS_EXTERNAL_STORAGE).should('not.exist')
 
 		// Finally, the storage should be accessible
@@ -81,8 +81,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
 	})
 
 	it('Create a user storage with GLOBAL user credentials', () => {
-		const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
-		createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' })
+		// Its not the public server address but the address so the server itself can connect to it
+		const base = 'http://localhost'
+		const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+		createStorageWithConfig('storage1', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })
 
 		cy.login(user2)
 		cy.visit('/apps/files/extstoragemounts')
@@ -119,8 +121,10 @@ describe('Files user credentials', { testIsolation: true }, () => {
 	})
 
 	it('Create another user storage while reusing GLOBAL user credentials', () => {
-		const url = Cypress.config('baseUrl') + '/remote.php/dav/files/' + storageUser.userId
-		createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host: url.replace('index.php/', ''), secure: 'false' })
+		// Its not the public server address but the address so the server itself can connect to it
+		const base = 'http://localhost'
+		const host = `${base}/remote.php/dav/files/${storageUser.userId}`
+		createStorageWithConfig('storage2', StorageBackend.DAV, AuthBackend.UserGlobalAuth, { host, secure: 'false' })
 
 		cy.login(user2)
 		cy.visit('/apps/files/extstoragemounts')
diff --git a/cypress/e2e/files_versions/version_deletion.cy.ts b/cypress/e2e/files_versions/version_deletion.cy.ts
index 944cc7a9fa8..b49aa872639 100644
--- a/cypress/e2e/files_versions/version_deletion.cy.ts
+++ b/cypress/e2e/files_versions/version_deletion.cy.ts
@@ -59,7 +59,6 @@ describe('Versions restoration', () => {
 		})
 
 		it('Does not work without delete permission through direct API access', () => {
-			let hostname: string
 			let fileId: string|undefined
 			let versionId: string|undefined
 
@@ -68,24 +67,30 @@ describe('Versions restoration', () => {
 					navigateToFolder(folderName)
 					openVersionsPanel(randomFilePath)
 
-					cy.url().then(url => { hostname = new URL(url).hostname })
-					getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
-					cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+					getRowForFile(randomFileName)
+						.should('be.visible')
+						.invoke('attr', 'data-cy-files-list-row-fileid')
+						.then(($fileId) => { fileId = $fileId })
 
+					cy.get('[data-files-versions-version]')
+						.eq(1)
+						.invoke('attr', 'data-files-versions-version')
+						.then(($versionId) => { versionId = $versionId })
+
+					cy.logout()
 					cy.then(() => {
-						cy.logout()
-						cy.request({
+						const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+						return cy.request({
 							method: 'DELETE',
+							url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							auth: { user: recipient.userId, pass: recipient.password },
 							headers: {
 								cookie: '',
 							},
-							url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							failOnStatusCode: false,
 						})
-							.then(({ status }) => {
-								expect(status).to.equal(403)
-							})
+					}).then(({ status }) => {
+						expect(status).to.equal(403)
 					})
 				})
 		})
diff --git a/cypress/e2e/files_versions/version_download.cy.ts b/cypress/e2e/files_versions/version_download.cy.ts
index e444749d56b..7e84a56cb5e 100644
--- a/cypress/e2e/files_versions/version_download.cy.ts
+++ b/cypress/e2e/files_versions/version_download.cy.ts
@@ -52,31 +52,36 @@ describe('Versions download', () => {
 		})
 
 		it('Does not work without download permission through direct API access', () => {
-			let hostname: string
 			let fileId: string|undefined
 			let versionId: string|undefined
 
 			setupTestSharedFileFromUser(user, randomFileName, { download: false })
-				.then(recipient => {
+				.then((recipient) => {
 					openVersionsPanel(randomFileName)
 
-					cy.url().then(url => { hostname = new URL(url).hostname })
-					getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
-					cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+					getRowForFile(randomFileName)
+						.should('be.visible')
+						.invoke('attr', 'data-cy-files-list-row-fileid')
+						.then(($fileId) => { fileId = $fileId })
 
+					cy.get('[data-files-versions-version]')
+						.eq(1)
+						.invoke('attr', 'data-files-versions-version')
+						.then(($versionId) => { versionId = $versionId })
+
+					cy.logout()
 					cy.then(() => {
-						cy.logout()
-						cy.request({
+						const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+						return cy.request({
+							url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							auth: { user: recipient.userId, pass: recipient.password },
 							headers: {
 								cookie: '',
 							},
-							url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							failOnStatusCode: false,
 						})
-							.then(({ status }) => {
-								expect(status).to.equal(403)
-							})
+					}).then(({ status }) => {
+						expect(status).to.equal(403)
 					})
 				})
 		})
diff --git a/cypress/e2e/files_versions/version_naming.cy.ts b/cypress/e2e/files_versions/version_naming.cy.ts
index 980ae338490..ff299c53227 100644
--- a/cypress/e2e/files_versions/version_naming.cy.ts
+++ b/cypress/e2e/files_versions/version_naming.cy.ts
@@ -69,10 +69,17 @@ describe('Versions naming', () => {
 		})
 
 		context('without edit permission', () => {
-			it('Does not show action', () => {
-				setupTestSharedFileFromUser(user, randomFileName, { update: false })
-				openVersionsPanel(randomFileName)
+			let recipient: User
 
+			beforeEach(() => {
+				setupTestSharedFileFromUser(user, randomFileName, { update: false })
+					.then(($recipient) => {
+						recipient = $recipient
+						openVersionsPanel(randomFileName)
+					})
+			})
+
+			it('Does not show action', () => {
 				cy.get('[data-files-versions-version]').eq(0).find('.action-item__menutoggle').should('not.exist')
 				cy.get('[data-files-versions-version]').eq(0).get('[data-cy-version-action="label"]').should('not.exist')
 
@@ -81,45 +88,45 @@ describe('Versions naming', () => {
 			})
 
 			it('Does not work without update permission through direct API access', () => {
-				let hostname: string
 				let fileId: string|undefined
 				let versionId: string|undefined
 
-				setupTestSharedFileFromUser(user, randomFileName, { update: false })
-					.then(recipient => {
-						openVersionsPanel(randomFileName)
+				getRowForFile(randomFileName)
+					.should('be.visible')
+					.invoke('attr', 'data-cy-files-list-row-fileid')
+					.then(($fileId) => { fileId = $fileId })
 
-						cy.url().then(url => { hostname = new URL(url).hostname })
-						getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
-						cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+				cy.get('[data-files-versions-version]')
+					.eq(1)
+					.invoke('attr', 'data-files-versions-version')
+					.then(($versionId) => { versionId = $versionId })
 
-						cy.then(() => {
-							cy.logout()
-							cy.request({
-								method: 'PROPPATCH',
-								auth: { user: recipient.userId, pass: recipient.password },
-								headers: {
-									cookie: '',
-								},
-								body: `<?xml version="1.0"?>
-									<d:propertyupdate xmlns:d="DAV:"
-										xmlns:oc="http://owncloud.org/ns"
-										xmlns:nc="http://nextcloud.org/ns"
-										xmlns:ocs="http://open-collaboration-services.org/ns">
-									<d:set>
-										<d:prop>
-											<nc:version-label>not authorized labeling</nc:version-label>
-										</d:prop>
-									</d:set>
-									</d:propertyupdate>`,
-								url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
-								failOnStatusCode: false,
-							})
-								.then(({ status }) => {
-									expect(status).to.equal(403)
-								})
-						})
+				cy.logout()
+				cy.then(() => {
+					const base = Cypress.config('baseUrl')!.replace(/index\.php\/?/, '')
+					return cy.request({
+						method: 'PROPPATCH',
+						url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
+						auth: { user: recipient.userId, pass: recipient.password },
+						headers: {
+							cookie: '',
+						},
+						body: `<?xml version="1.0"?>
+							<d:propertyupdate xmlns:d="DAV:"
+								xmlns:oc="http://owncloud.org/ns"
+								xmlns:nc="http://nextcloud.org/ns"
+								xmlns:ocs="http://open-collaboration-services.org/ns">
+							<d:set>
+								<d:prop>
+									<nc:version-label>not authorized labeling</nc:version-label>
+								</d:prop>
+							</d:set>
+							</d:propertyupdate>`,
+						failOnStatusCode: false,
 					})
+				}).then(({ status }) => {
+					expect(status).to.equal(403)
+				})
 			})
 		})
 	})
diff --git a/cypress/e2e/files_versions/version_restoration.cy.ts b/cypress/e2e/files_versions/version_restoration.cy.ts
index 94c09bb9ffc..34360808f61 100644
--- a/cypress/e2e/files_versions/version_restoration.cy.ts
+++ b/cypress/e2e/files_versions/version_restoration.cy.ts
@@ -77,33 +77,38 @@ describe('Versions restoration', () => {
 		})
 
 		it('Does not work without update permission through direct API access', () => {
-			let hostname: string
 			let fileId: string|undefined
 			let versionId: string|undefined
 
 			setupTestSharedFileFromUser(user, randomFileName, { update: false })
-				.then(recipient => {
+				.then((recipient) => {
 					openVersionsPanel(randomFileName)
 
-					cy.url().then(url => { hostname = new URL(url).hostname })
-					getRowForFile(randomFileName).invoke('attr', 'data-cy-files-list-row-fileid').then(_fileId => { fileId = _fileId })
-					cy.get('[data-files-versions-version]').eq(1).invoke('attr', 'data-files-versions-version').then(_versionId => { versionId = _versionId })
+					getRowForFile(randomFileName)
+						.should('be.visible')
+						.invoke('attr', 'data-cy-files-list-row-fileid')
+						.then(($fileId) => { fileId = $fileId })
 
+					cy.get('[data-files-versions-version]')
+						.eq(1)
+						.invoke('attr', 'data-files-versions-version')
+						.then(($versionId) => { versionId = $versionId })
+
+					cy.logout()
 					cy.then(() => {
-						cy.logout()
-						cy.request({
+						const base = Cypress.config('baseUrl')!.replace(/\/index\.php\/?$/, '')
+						return cy.request({
 							method: 'MOVE',
+							url: `${base}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							auth: { user: recipient.userId, pass: recipient.password },
 							headers: {
 								cookie: '',
-								Destination: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/restore/target`,
+								Destination: `${base}}/remote.php/dav/versions/${recipient.userId}/restore/target`,
 							},
-							url: `http://${hostname}/remote.php/dav/versions/${recipient.userId}/versions/${fileId}/${versionId}`,
 							failOnStatusCode: false,
 						})
-							.then(({ status }) => {
-								expect(status).to.equal(403)
-							})
+					}).then(({ status }) => {
+						expect(status).to.equal(403)
 					})
 				})
 		})
diff --git a/cypress/e2e/login/webauth.cy.ts b/cypress/e2e/login/webauth.cy.ts
new file mode 100644
index 00000000000..fb67ed7f21c
--- /dev/null
+++ b/cypress/e2e/login/webauth.cy.ts
@@ -0,0 +1,152 @@
+/**
+ * SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+
+import type { User } from '@nextcloud/cypress'
+
+interface IChromeVirtualAuthenticator {
+	authenticatorId: string
+}
+
+/**
+ * Create a virtual authenticator using chrome debug protocol
+ */
+async function createAuthenticator(): Promise<IChromeVirtualAuthenticator> {
+	await Cypress.automation('remote:debugger:protocol', {
+		command: 'WebAuthn.enable',
+	})
+	const authenticator = await Cypress.automation('remote:debugger:protocol', {
+		command: 'WebAuthn.addVirtualAuthenticator',
+		params: {
+			options: {
+				protocol: 'ctap2',
+				ctap2Version: 'ctap2_1',
+				hasUserVerification: true,
+				transport: 'usb',
+				automaticPresenceSimulation: true,
+				isUserVerified: true,
+			},
+		},
+	})
+	return authenticator
+}
+
+/**
+ * Delete a virtual authenticator using chrome devbug protocol
+ *
+ * @param authenticator the authenticator object
+ */
+async function deleteAuthenticator(authenticator: IChromeVirtualAuthenticator) {
+	await Cypress.automation('remote:debugger:protocol', {
+		command: 'WebAuthn.removeVirtualAuthenticator',
+		params: {
+			...authenticator,
+		},
+	})
+}
+
+describe('Login using WebAuthn', () => {
+	let authenticator: IChromeVirtualAuthenticator
+	let user: User
+
+	afterEach(() => {
+		cy.deleteUser(user)
+			.then(() => deleteAuthenticator(authenticator))
+	})
+
+	beforeEach(() => {
+		cy.createRandomUser()
+			.then(($user) => {
+				user = $user
+				cy.login(user)
+			})
+			.then(() => createAuthenticator())
+			.then(($authenticator) => {
+				authenticator = $authenticator
+				cy.log('Created virtual authenticator')
+			})
+	})
+
+	it('add and delete WebAuthn', () => {
+		cy.intercept('**/settings/api/personal/webauthn/registration').as('webauthn')
+		cy.visit('/settings/user/security')
+
+		cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+
+		cy.findByRole('button', { name: /Add WebAuthn device/i })
+			.should('be.visible')
+			.click()
+
+		cy.wait('@webauthn')
+
+		cy.findByRole('textbox', { name: /Device name/i })
+			.should('be.visible')
+			.type('test device{enter}')
+
+		cy.wait('@webauthn')
+
+		cy.contains('[role="note"]', /No devices configured/i).should('not.exist')
+
+		cy.findByRole('list', { name: /following devices are configured for your account/i })
+			.should('be.visible')
+			.contains('li', 'test device')
+			.should('be.visible')
+			.findByRole('button', { name: /Actions/i })
+			.click()
+
+		cy.findByRole('menuitem', { name: /Delete/i })
+			.should('be.visible')
+			.click()
+
+		cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+		cy.findByRole('list', { name: /following devices are configured for your account/i })
+			.should('not.exist')
+
+		cy.reload()
+		cy.contains('[role="note"]', /No devices configured/i).should('be.visible')
+	})
+
+	it('add WebAuthn and login', () => {
+		cy.intercept('GET', '**/settings/api/personal/webauthn/registration').as('webauthnSetupInit')
+		cy.intercept('POST', '**/settings/api/personal/webauthn/registration').as('webauthnSetupDone')
+		cy.intercept('POST', '**/login/webauthn/start').as('webauthnLogin')
+
+		cy.visit('/settings/user/security')
+
+		cy.findByRole('button', { name: /Add WebAuthn device/i })
+			.should('be.visible')
+			.click()
+		cy.wait('@webauthnSetupInit')
+
+		cy.findByRole('textbox', { name: /Device name/i })
+			.should('be.visible')
+			.type('test device{enter}')
+		cy.wait('@webauthnSetupDone')
+
+		cy.findByRole('list', { name: /following devices are configured for your account/i })
+			.should('be.visible')
+			.findByText('test device')
+			.should('be.visible')
+
+		cy.logout()
+		cy.visit('/login')
+
+		cy.findByRole('button', { name: /Log in with a device/i })
+			.should('be.visible')
+			.click()
+
+		cy.findByRole('form', { name: /Log in with a device/i })
+			.should('be.visible')
+			.findByRole('textbox', { name: /Login or email/i })
+			.should('be.visible')
+			.type(`{selectAll}${user.userId}`)
+
+		cy.findByRole('button', { name: /Log in/i })
+			.click()
+		cy.wait('@webauthnLogin')
+
+		// Then I see that the current page is the Files app
+		cy.url().should('match', /apps\/dashboard(\/|$)/)
+	})
+})