refactor(encryption): migrate to Vue 3 and Typescript and script setup

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-13 14:44:53 +01:00
parent ab8e4e60ea
commit 108858daef
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
28 changed files with 664 additions and 430 deletions

View file

@ -1,32 +0,0 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2014-2015 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
/**
* @namespace OC
*/
OC.Encryption = _.extend(OC.Encryption || {}, {
displayEncryptionWarning: function() {
if (!OC.currentUser || !OC.Notification.isHidden()) {
return
}
$.get(
OC.generateUrl('/apps/encryption/ajax/getStatus'),
function(result) {
if (result.status === 'interactionNeeded') {
OC.Notification.show(result.data.message)
}
},
)
},
})
window.addEventListener('DOMContentLoaded', function() {
// wait for other apps/extensions to register their event handlers and file actions
// in the "ready" clause
_.defer(function() {
OC.Encryption.displayEncryptionWarning()
})
})

View file

@ -1,80 +0,0 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2013-2015 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
window.addEventListener('DOMContentLoaded', function() {
$('input:button[name="enableRecoveryKey"]').click(function() {
const recoveryStatus = $(this).attr('status')
const newRecoveryStatus = (1 + parseInt(recoveryStatus)) % 2
const buttonValue = $(this).attr('value')
const recoveryPassword = $('#encryptionRecoveryPassword').val()
const confirmPassword = $('#repeatEncryptionRecoveryPassword').val()
OC.msg.startSaving('#encryptionSetRecoveryKey .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/adminRecovery'),
{
adminEnableRecovery: newRecoveryStatus,
recoveryPassword,
confirmPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#encryptionSetRecoveryKey .msg', data.data.message)
if (newRecoveryStatus === 0) {
$('p[name="changeRecoveryPasswordBlock"]').addClass('hidden')
$('input:button[name="enableRecoveryKey"]').attr('value', 'Enable recovery key')
$('input:button[name="enableRecoveryKey"]').attr('status', '0')
} else {
$('input:password[name="changeRecoveryPassword"]').val('')
$('p[name="changeRecoveryPasswordBlock"]').removeClass('hidden')
$('input:button[name="enableRecoveryKey"]').attr('value', 'Disable recovery key')
$('input:button[name="enableRecoveryKey"]').attr('status', '1')
}
})
.fail(function(jqXHR) {
$('input:button[name="enableRecoveryKey"]').attr('value', buttonValue)
$('input:button[name="enableRecoveryKey"]').attr('status', recoveryStatus)
OC.msg.finishedError('#encryptionSetRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message)
})
})
$('#repeatEncryptionRecoveryPassword').keyup(function(event) {
if (event.keyCode == 13) {
$('#enableRecoveryKey').click()
}
})
// change recovery password
$('button:button[name="submitChangeRecoveryKey"]').click(function() {
const oldRecoveryPassword = $('#oldEncryptionRecoveryPassword').val()
const newRecoveryPassword = $('#newEncryptionRecoveryPassword').val()
const confirmNewPassword = $('#repeatedNewEncryptionRecoveryPassword').val()
OC.msg.startSaving('#encryptionChangeRecoveryKey .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/changeRecoveryPassword'),
{
oldPassword: oldRecoveryPassword,
newPassword: newRecoveryPassword,
confirmPassword: confirmNewPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#encryptionChangeRecoveryKey .msg', data.data.message)
})
.fail(function(jqXHR) {
OC.msg.finishedError('#encryptionChangeRecoveryKey .msg', JSON.parse(jqXHR.responseText).data.message)
})
})
$('#encryptHomeStorage').change(function() {
$.post(
OC.generateUrl('/apps/encryption/ajax/setEncryptHomeStorage'),
{
encryptHomeStorage: this.checked,
},
)
})
})

View file

@ -1,64 +0,0 @@
/**
* SPDX-FileCopyrightText: 2020 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2013-2015 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
OC.Encryption = _.extend(OC.Encryption || {}, {
updatePrivateKeyPassword: function() {
const oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val()
const newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val()
OC.msg.startSaving('#ocDefaultEncryptionModule .msg')
$.post(
OC.generateUrl('/apps/encryption/ajax/updatePrivateKeyPassword'),
{
oldPassword: oldPrivateKeyPassword,
newPassword: newPrivateKeyPassword,
},
).done(function(data) {
OC.msg.finishedSuccess('#ocDefaultEncryptionModule .msg', data.message)
}).fail(function(jqXHR) {
OC.msg.finishedError('#ocDefaultEncryptionModule .msg', JSON.parse(jqXHR.responseText).message)
})
},
})
window.addEventListener('DOMContentLoaded', function() {
// Trigger ajax on recoveryAdmin status change
$('input:radio[name="userEnableRecovery"]').change(function() {
const recoveryStatus = $(this).val()
OC.msg.startAction('#userEnableRecovery .msg', 'Updating recovery keys. This can take some time...')
$.post(
OC.generateUrl('/apps/encryption/ajax/userSetRecovery'),
{
userEnableRecovery: recoveryStatus,
},
).done(function(data) {
OC.msg.finishedSuccess('#userEnableRecovery .msg', data.data.message)
})
.fail(function(jqXHR) {
OC.msg.finishedError('#userEnableRecovery .msg', JSON.parse(jqXHR.responseText).data.message)
})
// Ensure page is not reloaded on form submit
return false
})
// update private key password
$('input:password[name="changePrivateKeyPassword"]').keyup(function(event) {
const oldPrivateKeyPassword = $('input:password[id="oldPrivateKeyPassword"]').val()
const newPrivateKeyPassword = $('input:password[id="newPrivateKeyPassword"]').val()
if (newPrivateKeyPassword !== '' && oldPrivateKeyPassword !== '') {
$('button:button[name="submitChangePrivateKeyPassword"]').removeAttr('disabled')
if (event.which === 13) {
OC.Encryption.updatePrivateKeyPassword()
}
} else {
$('button:button[name="submitChangePrivateKeyPassword"]').attr('disabled', 'true')
}
})
$('button:button[name="submitChangePrivateKeyPassword"]').click(function() {
OC.Encryption.updatePrivateKeyPassword()
})
})

View file

@ -12,35 +12,23 @@ use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\DataResponse;
use OCP\IConfig;
use OCP\Encryption\Exceptions\GenericEncryptionException;
use OCP\IL10N;
use OCP\IRequest;
use Psr\Log\LoggerInterface;
class RecoveryController extends Controller {
/**
* @param string $AppName
* @param IRequest $request
* @param IConfig $config
* @param IL10N $l
* @param Recovery $recovery
*/
public function __construct(
$appName,
string $appName,
IRequest $request,
private IConfig $config,
private IL10N $l,
private Recovery $recovery,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}
/**
* @param string $recoveryPassword
* @param string $confirmPassword
* @param string $adminEnableRecovery
* @return DataResponse
*/
public function adminRecovery($recoveryPassword, $confirmPassword, $adminEnableRecovery) {
public function adminRecovery(string $recoveryPassword, string $confirmPassword, bool $adminEnableRecovery): DataResponse {
// Check if both passwords are the same
if (empty($recoveryPassword)) {
$errorMessage = $this->l->t('Missing recovery key password');
@ -60,28 +48,28 @@ class RecoveryController extends Controller {
Http::STATUS_BAD_REQUEST);
}
if (isset($adminEnableRecovery) && $adminEnableRecovery === '1') {
if ($this->recovery->enableAdminRecovery($recoveryPassword)) {
return new DataResponse(['data' => ['message' => $this->l->t('Recovery key successfully enabled')]]);
try {
if ($adminEnableRecovery) {
if ($this->recovery->enableAdminRecovery($recoveryPassword)) {
return new DataResponse(['data' => ['message' => $this->l->t('Recovery key successfully enabled')]]);
}
return new DataResponse(['data' => ['message' => $this->l->t('Could not enable recovery key. Please check your recovery key password!')]], Http::STATUS_BAD_REQUEST);
} else {
if ($this->recovery->disableAdminRecovery($recoveryPassword)) {
return new DataResponse(['data' => ['message' => $this->l->t('Recovery key successfully disabled')]]);
}
return new DataResponse(['data' => ['message' => $this->l->t('Could not disable recovery key. Please check your recovery key password!')]], Http::STATUS_BAD_REQUEST);
}
return new DataResponse(['data' => ['message' => $this->l->t('Could not enable recovery key. Please check your recovery key password!')]], Http::STATUS_BAD_REQUEST);
} elseif (isset($adminEnableRecovery) && $adminEnableRecovery === '0') {
if ($this->recovery->disableAdminRecovery($recoveryPassword)) {
return new DataResponse(['data' => ['message' => $this->l->t('Recovery key successfully disabled')]]);
} catch (\Exception $e) {
$this->logger->error('Error enabling or disabling recovery key', ['exception' => $e]);
if ($e instanceof GenericEncryptionException) {
return new DataResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new DataResponse(['data' => ['message' => $this->l->t('Could not disable recovery key. Please check your recovery key password!')]], Http::STATUS_BAD_REQUEST);
return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
// this response should never be sent but just in case.
return new DataResponse(['data' => ['message' => $this->l->t('Missing parameters')]], Http::STATUS_BAD_REQUEST);
}
/**
* @param string $newPassword
* @param string $oldPassword
* @param string $confirmPassword
* @return DataResponse
*/
public function changeRecoveryPassword($newPassword, $oldPassword, $confirmPassword) {
public function changeRecoveryPassword(string $newPassword, string $oldPassword, string $confirmPassword): DataResponse {
//check if both passwords are the same
if (empty($oldPassword)) {
$errorMessage = $this->l->t('Please provide the old recovery password');
@ -103,23 +91,30 @@ class RecoveryController extends Controller {
return new DataResponse(['data' => ['message' => $errorMessage]], Http::STATUS_BAD_REQUEST);
}
$result = $this->recovery->changeRecoveryKeyPassword($newPassword,
$oldPassword);
try {
$result = $this->recovery->changeRecoveryKeyPassword($newPassword,
$oldPassword);
if ($result) {
return new DataResponse(
[
'data' => [
'message' => $this->l->t('Password successfully changed.')]
]
);
}
return new DataResponse(
[
if ($result) {
return new DataResponse(
[
'data' => [
'message' => $this->l->t('Password successfully changed.')]
]
);
}
return new DataResponse([
'data' => [
'message' => $this->l->t('Could not change the password. Maybe the old password was not correct.')
]
], Http::STATUS_BAD_REQUEST);
} catch (\Exception $e) {
$this->logger->error('Error changing recovery password', ['exception' => $e]);
if ($e instanceof GenericEncryptionException) {
return new DataResponse(['data' => ['message' => $e->getMessage()]], Http::STATUS_INTERNAL_SERVER_ERROR);
}
return new DataResponse([], Http::STATUS_INTERNAL_SERVER_ERROR);
}
}
/**

View file

@ -68,8 +68,10 @@ class StatusController extends Controller {
return new DataResponse(
[
'status' => $status,
'initStatus' => $this->session->getStatus(),
'data' => [
'message' => $message]
'message' => $message,
],
]
);
}

View file

@ -7,10 +7,13 @@
namespace OCA\Encryption\Settings;
use OC\Files\View;
use OCA\Encryption\AppInfo\Application;
use OCA\Encryption\Crypto\Crypt;
use OCA\Encryption\Session;
use OCA\Encryption\Util;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\ISession;
@ -27,6 +30,8 @@ class Admin implements ISettings {
private IConfig $config,
private IUserManager $userManager,
private ISession $session,
private IInitialState $initialState,
private IAppConfig $appConfig,
) {
}
@ -48,19 +53,21 @@ class Admin implements ISettings {
$this->userManager);
// Check if an adminRecovery account is enabled for recovering files after lost pwd
$recoveryAdminEnabled = $this->config->getAppValue('encryption', 'recoveryAdminEnabled', '0');
$recoveryAdminEnabled = $this->appConfig->getValueBool('encryption', 'recoveryAdminEnabled');
$session = new Session($this->session);
$encryptHomeStorage = $util->shouldEncryptHomeStorage();
$parameters = [
$this->initialState->provideInitialState('adminSettings', [
'recoveryEnabled' => $recoveryAdminEnabled,
'initStatus' => $session->getStatus(),
'encryptHomeStorage' => $encryptHomeStorage,
'masterKeyEnabled' => $util->isMasterKeyEnabled(),
];
]);
return new TemplateResponse('encryption', 'settings-admin', $parameters, '');
\OCP\Util::addStyle(Application::APP_ID, 'settings_admin');
\OCP\Util::addScript(Application::APP_ID, 'settings_admin');
return new TemplateResponse(Application::APP_ID, 'settings', renderAs: '');
}
/**

View file

@ -6,20 +6,25 @@
*/
namespace OCA\Encryption\Settings;
use OCA\Encryption\AppInfo\Application;
use OCA\Encryption\Session;
use OCA\Encryption\Util;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\AppFramework\Services\IInitialState;
use OCP\Encryption\IManager;
use OCP\IAppConfig;
use OCP\IUserSession;
use OCP\Settings\ISettings;
class Personal implements ISettings {
public function __construct(
private IConfig $config,
private Session $session,
private Util $util,
private IUserSession $userSession,
private IInitialState $initialState,
private IAppConfig $appConfig,
private IManager $manager,
) {
}
@ -28,7 +33,7 @@ class Personal implements ISettings {
* @since 9.1
*/
public function getForm() {
$recoveryAdminEnabled = $this->config->getAppValue('encryption', 'recoveryAdminEnabled');
$recoveryAdminEnabled = $this->appConfig->getValueBool('encryption', 'recoveryAdminEnabled');
$privateKeySet = $this->session->isPrivateKeySet();
if (!$recoveryAdminEnabled && $privateKeySet) {
@ -38,20 +43,23 @@ class Personal implements ISettings {
$userId = $this->userSession->getUser()->getUID();
$recoveryEnabledForUser = $this->util->isRecoveryEnabledForUser($userId);
$parameters = [
$this->initialState->provideInitialState('personalSettings', [
'recoveryEnabled' => $recoveryAdminEnabled,
'recoveryEnabledForUser' => $recoveryEnabledForUser,
'privateKeySet' => $privateKeySet,
'initialized' => $this->session->getStatus(),
];
return new TemplateResponse('encryption', 'settings-personal', $parameters, '');
]);
\OCP\Util::addStyle(Application::APP_ID, 'settings_personal');
\OCP\Util::addScript(Application::APP_ID, 'settings_personal');
return new TemplateResponse(Application::APP_ID, 'settings', renderAs: '');
}
/**
* @return string the section ID, e.g. 'sharing'
* @since 9.1
*/
public function getSection() {
if (!$this->manager->isEnabled()) {
return null;
}
return 'security';
}

View file

@ -0,0 +1,46 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { watchDebounced } from '@vueuse/core'
import { ref, watch } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const encryptHomeStorage = defineModel<boolean>({ required: true })
const isSavingHomeStorageEncryption = ref(false)
watch(encryptHomeStorage, () => {
isSavingHomeStorageEncryption.value = true
})
watchDebounced(encryptHomeStorage, async (encryptHomeStorage, oldValue) => {
if (encryptHomeStorage === oldValue) {
// user changed their mind (likely quickly toggled), do nothing
isSavingHomeStorageEncryption.value = false
return
}
try {
await axios.post(
generateUrl('/apps/encryption/ajax/setEncryptHomeStorage'),
{ encryptHomeStorage },
)
} finally {
isSavingHomeStorageEncryption.value = false
}
}, { debounce: 800 })
</script>
<template>
<NcCheckboxRadioSwitch
v-model="encryptHomeStorage"
:loading="isSavingHomeStorageEncryption"
:description="t('encryption', 'Enabling this option encrypts all files stored on the main storage, otherwise only files on external storage will be encrypted')"
type="switch">
{{ t('encryption', 'Encrypt the home storage') }}
</NcCheckboxRadioSwitch>
</template>

View file

@ -0,0 +1,93 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { showSuccess } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { computed, ref, useTemplateRef } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcFormGroup from '@nextcloud/vue/components/NcFormGroup'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import { logger } from '../utils/logger.ts'
const recoveryEnabled = defineModel<boolean>({ required: true })
const formElement = useTemplateRef('form')
const isLoading = ref(false)
const hasError = ref(false)
const password = ref('')
const confirmPassword = ref('')
const passwordMatch = computed(() => password.value === confirmPassword.value)
/**
* Handle the form submission to enable or disable the admin recovery key
*/
async function onSubmit() {
if (isLoading.value) {
return
}
if (!passwordMatch.value) {
return
}
hasError.value = false
isLoading.value = true
try {
const { data } = await axios.post(
generateUrl('/apps/encryption/ajax/adminRecovery'),
{
adminEnableRecovery: !recoveryEnabled.value,
recoveryPassword: password.value,
confirmPassword: confirmPassword.value,
},
)
recoveryEnabled.value = !recoveryEnabled.value
password.value = confirmPassword.value = ''
formElement.value?.reset()
if (data.data.message) {
showSuccess(data.data.message)
}
} catch (error) {
hasError.value = true
logger.error('Failed to update recovery key settings', { error })
} finally {
isLoading.value = false
}
}
</script>
<template>
<form ref="form" @submit.prevent="onSubmit">
<NcFormGroup
:label="recoveryEnabled ? t('encryption', 'Disable recovery key') : t('encryption', 'Enable recovery key')"
:description="t('encryption', 'The recovery key is an additional encryption key used to encrypt files. It is used to recover files from an account if the password is forgotten.')">
<NcPasswordField
v-model="password"
required
name="password"
:label="t('encryption', 'Recovery key password')" />
<NcPasswordField
v-model="confirmPassword"
required
name="confirmPassword"
:error="!!confirmPassword && !passwordMatch"
:helper-text="(passwordMatch || !confirmPassword) ? '' : t('encryption', 'Passwords do not match fields')"
:label="t('encryption', 'Repeat recovery key password')" />
<NcButton type="submit" :variant="recoveryEnabled ? 'error' : 'primary'">
{{ recoveryEnabled ? t('encryption', 'Disable recovery key') : t('encryption', 'Enable recovery key') }}
</NcButton>
<NcNoteCard v-if="hasError" type="error">
{{ t('encryption', 'An error occurred while updating the recovery key settings. Please try again.') }}
</NcNoteCard>
</NcFormGroup>
</form>
</template>

View file

@ -0,0 +1,98 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { computed, ref, useTemplateRef } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcFormGroup from '@nextcloud/vue/components/NcFormGroup'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcPasswordField from '@nextcloud/vue/components/NcPasswordField'
import { logger } from '../utils/logger.ts'
const formElement = useTemplateRef('form')
const isLoading = ref(false)
const hasError = ref(false)
const oldPassword = ref('')
const password = ref('')
const confirmPassword = ref('')
const passwordMatch = computed(() => password.value === confirmPassword.value)
/**
* Handle the form submission to change the admin recovery key password
*/
async function onSubmit() {
if (isLoading.value) {
return
}
if (!passwordMatch.value) {
return
}
hasError.value = false
isLoading.value = true
try {
await axios.post(
generateUrl('/apps/encryption/ajax/changeRecoveryPassword'),
{
oldPassword: oldPassword.value,
newPassword: password.value,
confirmPassword: confirmPassword.value,
},
)
oldPassword.value = password.value = confirmPassword.value = ''
formElement.value?.reset()
} catch (error) {
hasError.value = true
logger.error('Failed to update recovery key settings', { error })
} finally {
isLoading.value = false
}
}
</script>
<template>
<form ref="form" :class="$style.settingsAdminRecoveryKeyChange" @submit.prevent="onSubmit">
<NcFormGroup
:label="t('encryption', 'Change recovery key password')">
<NcPasswordField
v-model="oldPassword"
required
name="oldPassword"
:label="t('encryption', 'Old recovery key password')" />
<NcPasswordField
v-model="password"
required
name="password"
:label="t('encryption', 'New recovery key password')" />
<NcPasswordField
v-model="confirmPassword"
required
name="confirmPassword"
:error="!passwordMatch && !!confirmPassword"
:helper-text="(passwordMatch || !confirmPassword) ? '' : t('encryption', 'Passwords do not match fields')"
:label="t('encryption', 'Repeat new recovery key password')" />
<NcButton type="submit" variant="primary">
{{ t('encryption', 'Change recovery key password') }}
</NcButton>
<NcNoteCard v-if="hasError" type="error">
{{ t('encryption', 'An error occurred while changing the recovery key password. Please try again.') }}
</NcNoteCard>
</NcFormGroup>
</form>
</template>
<style module>
.settingsAdminRecoveryKeyChange {
margin-top: var(--clickable-area-small);
}
</style>

View file

@ -0,0 +1,80 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { NcButton, NcFormGroup, NcNoteCard, NcPasswordField } from '@nextcloud/vue'
import { ref, useTemplateRef } from 'vue'
defineProps<{
recoveryEnabledForUser: boolean
}>()
const emit = defineEmits<{
updated: []
}>()
const formElement = useTemplateRef('form')
const isLoading = ref(false)
const hasError = ref(false)
const oldPrivateKeyPassword = ref('')
const newPrivateKeyPassword = ref('')
/**
* Handle the form submission to change the private key password
*/
async function onSubmit() {
if (isLoading.value) {
return
}
isLoading.value = true
hasError.value = false
try {
await axios.post(
generateUrl('/apps/encryption/ajax/updatePrivateKeyPassword'),
{
oldPassword: oldPrivateKeyPassword.value,
newPassword: newPrivateKeyPassword.value,
},
)
oldPrivateKeyPassword.value = newPrivateKeyPassword.value = ''
formElement.value?.reset()
emit('updated')
} catch (error) {
if (isAxiosError(error) && error.response && error.response.data?.data?.message) {
showError(error.response.data.data.message)
}
hasError.value = true
} finally {
isLoading.value = false
}
}
</script>
<template>
<form ref="form" @submit.prevent="onSubmit">
<NcFormGroup
:label="t('encryption', 'Update private key password')"
:description="t('encryption', 'Your private key password no longer matches your log-in password. Set your old private key password to your current log-in password.')">
<NcNoteCard v-if="recoveryEnabledForUser">
{{ t('encryption', 'If you do not remember your old password you can ask your administrator to recover your files.') }}
</NcNoteCard>
<NcPasswordField :label="t('encryption', 'Old log-in password')" />
<NcPasswordField :label="t('encryption', 'Current log-in password')" />
<NcButton
type="submit"
variant="primary">
{{ t('encryption', 'Update') }}
</NcButton>
</NcFormGroup>
</form>
</template>

View file

@ -0,0 +1,54 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios, { isAxiosError } from '@nextcloud/axios'
import { showError, showLoading } from '@nextcloud/dialogs'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { watchDebounced } from '@vueuse/core'
import { ref, watch } from 'vue'
import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwitch'
const userEnableRecovery = defineModel<boolean>({ required: true })
const isLoading = ref(false)
watch(userEnableRecovery, () => {
isLoading.value = true
})
watchDebounced([userEnableRecovery], async ([newValue], [oldValue]) => {
if (newValue === oldValue) {
// user changed their mind (likely quickly toggled), do nothing
isLoading.value = false
return
}
const toast = showLoading(t('encryption', 'Updating recovery keys. This can take some time…'))
try {
await axios.post(
generateUrl('/apps/encryption/ajax/userSetRecovery'),
{ userEnableRecovery: userEnableRecovery.value },
)
} catch (error) {
userEnableRecovery.value = oldValue
if (isAxiosError(error) && error.response && error.response.data?.data?.message) {
showError(error.response.data.data.message)
}
} finally {
toast.hideToast()
isLoading.value = false
}
}, { debounce: 800 })
</script>
<template>
<NcCheckboxRadioSwitch
v-model="userEnableRecovery"
type="switch"
:loading="isLoading"
:description="t('encryption', 'Enabling this option will allow you to reobtain access to your encrypted files in case of password loss')">
{{ t('encryption', 'Enable password recovery') }}
</NcCheckboxRadioSwitch>
</template>

View file

@ -0,0 +1,21 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { showWarning } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
window.addEventListener('DOMContentLoaded', async function() {
if (getCurrentUser() === null) {
// skip for public pages
return
}
const { data } = await axios.get(generateUrl('/apps/encryption/ajax/getStatus'))
if (data.status === 'interactionNeeded') {
showWarning(data.data.message)
}
})

View file

@ -0,0 +1,10 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createApp } from 'vue'
import SettingsAdmin from './views/SettingsAdmin.vue'
const app = createApp(SettingsAdmin)
app.mount('#encryption-settings-section')

View file

@ -0,0 +1,10 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { createApp } from 'vue'
import SettingsPersonal from './views/SettingsPersonal.vue'
const app = createApp(SettingsPersonal)
app.mount('#encryption-settings-section')

View file

@ -0,0 +1,10 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getLoggerBuilder } from '@nextcloud/logger'
export const logger = getLoggerBuilder()
.setApp('encryption')
.build()

View file

@ -0,0 +1,10 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export const InitStatus = Object.freeze({
NotInitialized: '0',
InitExecuted: '1',
InitSuccessful: '2',
})

View file

@ -0,0 +1,40 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { NcNoteCard, NcSettingsSection } from '@nextcloud/vue'
import { ref } from 'vue'
import SettingsAdminHomeStorage from '../components/SettingsAdminHomeStorage.vue'
import SettingsAdminRecoveryKey from '../components/SettingsAdminRecoveryKey.vue'
import SettingsAdminRecoveryKeyChange from '../components/SettingsAdminRecoveryKeyChange.vue'
import { InitStatus } from '../utils/types.ts'
const adminSettings = loadState<{
recoveryEnabled: boolean
masterKeyEnabled: boolean
encryptHomeStorage: boolean
initStatus: typeof InitStatus[keyof typeof InitStatus]
}>('encryption', 'adminSettings')
const encryptHomeStorage = ref(adminSettings.encryptHomeStorage!)
const recoveryEnabled = ref(adminSettings.recoveryEnabled!)
</script>
<template>
<NcSettingsSection :name="t('encryption', 'Default encryption module')">
<NcNoteCard v-if="adminSettings.initStatus === InitStatus.NotInitialized && !adminSettings.masterKeyEnabled" type="warning">
{{ t('encryption', 'Encryption app is enabled but your keys are not initialized, please log-out and log-in again') }}
</NcNoteCard>
<template v-else>
<SettingsAdminHomeStorage v-model="encryptHomeStorage" />
<br>
<SettingsAdminRecoveryKey v-if="adminSettings.masterKeyEnabled" v-model="recoveryEnabled" />
<SettingsAdminRecoveryKeyChange v-if="adminSettings.masterKeyEnabled && recoveryEnabled" />
</template>
</NcSettingsSection>
</template>

View file

@ -0,0 +1,60 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import axios from '@nextcloud/axios'
import { showInfo } from '@nextcloud/dialogs'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import { ref } from 'vue'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import SettingsPersonalChangePrivateKey from '../components/SettingsPersonalChangePrivateKey.vue'
import SettingsPersonalEnableRecovery from '../components/SettingsPersonalEnableRecovery.vue'
import { logger } from '../utils/logger.ts'
import { InitStatus } from '../utils/types.ts'
const personalSettings = loadState<{
recoveryEnabled: boolean
recoveryEnabledForUser: boolean
privateKeySet: boolean
initialized: typeof InitStatus[keyof typeof InitStatus]
}>('encryption', 'personalSettings')
const initialized = ref(personalSettings.initialized)
const recoveryEnabledForUser = ref(personalSettings.recoveryEnabledForUser)
/**
* Reload encryption status
*/
async function reloadStatus() {
try {
const { data } = await axios.get(generateUrl('/apps/encryption/ajax/getStatus'))
initialized.value = data.initStatus
if (data.data.message) {
showInfo(data.data.message)
}
} catch (error) {
logger.error('Failed to fetch current encryption status', { error })
}
}
</script>
<template>
<NcSettingsSection :name="t('encryption', 'Basic encryption module')">
<NcNoteCard v-if="initialized === InitStatus.NotInitialized" type="warning">
{{ t('encryption', 'Encryption app is enabled but your keys are not initialized, please log-out and log-in again') }}
</NcNoteCard>
<SettingsPersonalChangePrivateKey
v-else-if="initialized === InitStatus.InitExecuted"
:recovery-enabled-for-user
@updated="reloadStatus" />
<SettingsPersonalEnableRecovery
v-else-if="personalSettings.recoveryEnabled && personalSettings.privateKeySet"
v-model="recoveryEnabledForUser" />
</NcSettingsSection>
</template>

View file

@ -1,83 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** @var array $_ */
/** @var \OCP\IL10N $l */
\OCP\Util::addScript('encryption', 'settings-admin', 'core');
style('encryption', 'settings-admin');
?>
<form id="ocDefaultEncryptionModule" class="sub-section">
<h3><?php p($l->t('Default encryption module')); ?></h3>
<?php if (!$_['initStatus'] && $_['masterKeyEnabled'] === false): ?>
<?php p($l->t('Encryption app is enabled but your keys are not initialized, please log-out and log-in again')); ?>
<?php else: ?>
<p id="encryptHomeStorageSetting">
<input type="checkbox" class="checkbox" name="encrypt_home_storage" id="encryptHomeStorage"
value="1" <?php if ($_['encryptHomeStorage']) {
print_unescaped('checked="checked"');
} ?> />
<label for="encryptHomeStorage"><?php p($l->t('Encrypt the home storage'));?></label></br>
<em><?php p($l->t('Enabling this option encrypts all files stored on the main storage, otherwise only files on external storage will be encrypted')); ?></em>
</p>
<br />
<?php if ($_['masterKeyEnabled'] === false): ?>
<p id="encryptionSetRecoveryKey">
<?php $_['recoveryEnabled'] === '0' ? p($l->t('Enable recovery key')) : p($l->t('Disable recovery key')); ?>
<span class="msg"></span>
<br/>
<em>
<?php p($l->t('The recovery key is an additional encryption key used to encrypt files. It is used to recover files from an account if the password is forgotten.')) ?>
</em>
<br/>
<input type="password"
name="encryptionRecoveryPassword"
id="encryptionRecoveryPassword"
placeholder="<?php p($l->t('Recovery key password')); ?>"/>
<input type="password"
name="encryptionRecoveryPassword"
id="repeatEncryptionRecoveryPassword"
placeholder="<?php p($l->t('Repeat recovery key password')); ?>"/>
<input type="button"
name="enableRecoveryKey"
id="enableRecoveryKey"
status="<?php p($_['recoveryEnabled']) ?>"
value="<?php $_['recoveryEnabled'] === '0' ? p($l->t('Enable recovery key')) : p($l->t('Disable recovery key')); ?>"/>
</p>
<br/><br/>
<p name="changeRecoveryPasswordBlock" id="encryptionChangeRecoveryKey" <?php if ($_['recoveryEnabled'] === '0') {
print_unescaped('class="hidden"');
}?>>
<?php p($l->t('Change recovery key password:')); ?>
<span class="msg"></span>
<br/>
<input
type="password"
name="changeRecoveryPassword"
id="oldEncryptionRecoveryPassword"
placeholder="<?php p($l->t('Old recovery key password')); ?>"/>
<br />
<input
type="password"
name="changeRecoveryPassword"
id="newEncryptionRecoveryPassword"
placeholder="<?php p($l->t('New recovery key password')); ?>"/>
<input
type="password"
name="changeRecoveryPassword"
id="repeatedNewEncryptionRecoveryPassword"
placeholder="<?php p($l->t('Repeat new recovery key password')); ?>"/>
<button
type="button"
name="submitChangeRecoveryKey">
<?php p($l->t('Change Password')); ?>
</button>
</p>
<?php endif; ?>
<?php endif; ?>
</form>

View file

@ -1,79 +0,0 @@
<?php
/**
* SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors
* SPDX-FileCopyrightText: 2016 ownCloud, Inc.
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** @var array $_ */
/** @var \OCP\IL10N $l */
\OCP\Util::addScript('encryption', 'settings-personal', 'core');
?>
<form id="ocDefaultEncryptionModule" class="section">
<h2 data-anchor-name="basic-encryption-module"><?php p($l->t('Basic encryption module')); ?></h2>
<?php if ($_['initialized'] === \OCA\Encryption\Session::NOT_INITIALIZED): ?>
<?php p($l->t('Encryption App is enabled, but your keys are not initialized. Please log-out and log-in again.')); ?>
<?php elseif ($_['initialized'] === \OCA\Encryption\Session::INIT_EXECUTED): ?>
<p>
<a name="changePKPasswd" />
<label for="changePrivateKeyPasswd">
<em><?php p($l->t('Your private key password no longer matches your log-in password.')); ?></em>
</label>
<br />
<?php p($l->t('Set your old private key password to your current log-in password:')); ?>
<?php if ($_['recoveryEnabledForUser']):
p(' ' . $l->t('If you do not remember your old password you can ask your administrator to recover your files.'));
endif; ?>
<br />
<input
type="password"
name="changePrivateKeyPassword"
id="oldPrivateKeyPassword" />
<label for="oldPrivateKeyPassword"><?php p($l->t('Old log-in password')); ?></label>
<br />
<input
type="password"
name="changePrivateKeyPassword"
id="newPrivateKeyPassword" />
<label for="newRecoveryPassword"><?php p($l->t('Current log-in password')); ?></label>
<br />
<button
type="button"
name="submitChangePrivateKeyPassword"
disabled><?php p($l->t('Update Private Key Password')); ?>
</button>
<span class="msg"></span>
</p>
<?php elseif ($_['recoveryEnabled'] && $_['privateKeySet'] && $_['initialized'] === \OCA\Encryption\Session::INIT_SUCCESSFUL): ?>
<br />
<p id="userEnableRecovery">
<label for="userEnableRecovery"><?php p($l->t('Enable password recovery:')); ?></label>
<span class="msg"></span>
<br />
<em><?php p($l->t('Enabling this option will allow you to reobtain access to your encrypted files in case of password loss')); ?></em>
<br />
<input
type="radio"
class="radio"
id="userEnableRecoveryCheckbox"
name="userEnableRecovery"
value="1"
<?php echo($_['recoveryEnabledForUser'] ? 'checked="checked"' : ''); ?> />
<label for="userEnableRecoveryCheckbox"><?php p($l->t('Enabled')); ?></label>
<br />
<input
type="radio"
class="radio"
id="userDisableRecoveryCheckbox"
name="userEnableRecovery"
value="0"
<?php echo($_['recoveryEnabledForUser'] === false ? 'checked="checked"' : ''); ?> />
<label for="userDisableRecoveryCheckbox"><?php p($l->t('Disabled')); ?></label>
</p>
<?php endif; ?>
</form>

View file

@ -0,0 +1,10 @@
<?php
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
?>
<div id="encryption-settings-section"></div>

View file

@ -16,6 +16,7 @@ use OCP\IConfig;
use OCP\IL10N;
use OCP\IRequest;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Log\LoggerInterface;
use Test\TestCase;
class RecoveryControllerTest extends TestCase {
@ -28,11 +29,11 @@ class RecoveryControllerTest extends TestCase {
public static function adminRecoveryProvider(): array {
return [
['test', 'test', '1', 'Recovery key successfully enabled', Http::STATUS_OK],
['', 'test', '1', 'Missing recovery key password', Http::STATUS_BAD_REQUEST],
['test', '', '1', 'Please repeat the recovery key password', Http::STATUS_BAD_REQUEST],
['test', 'something that doesn\'t match', '1', 'Repeated recovery key password does not match the provided recovery key password', Http::STATUS_BAD_REQUEST],
['test', 'test', '0', 'Recovery key successfully disabled', Http::STATUS_OK],
['test', 'test', true, 'Recovery key successfully enabled', Http::STATUS_OK],
['', 'test', true, 'Missing recovery key password', Http::STATUS_BAD_REQUEST],
['test', '', true, 'Please repeat the recovery key password', Http::STATUS_BAD_REQUEST],
['test', 'something that doesn\'t match', true, 'Repeated recovery key password does not match the provided recovery key password', Http::STATUS_BAD_REQUEST],
['test', 'test', false, 'Recovery key successfully disabled', Http::STATUS_OK],
];
}
@ -150,10 +151,12 @@ class RecoveryControllerTest extends TestCase {
->disableOriginalConstructor()
->getMock();
$this->controller = new RecoveryController('encryption',
$this->controller = new RecoveryController(
'encryption',
$this->requestMock,
$this->configMock,
$this->l10nMock,
$this->recoveryMock);
$this->recoveryMock,
$this->createMock(LoggerInterface::class),
);
}
}

View file

@ -56,7 +56,7 @@ class StatusControllerTest extends TestCase {
*/
#[\PHPUnit\Framework\Attributes\DataProvider('dataTestGetStatus')]
public function testGetStatus($status, $expectedStatus): void {
$this->sessionMock->expects($this->once())
$this->sessionMock->expects($this->atLeastOnce())
->method('getStatus')->willReturn($status);
$result = $this->controller->getStatus();
$data = $result->getData();

View file

@ -10,6 +10,8 @@ namespace OCA\Encryption\Tests\Settings;
use OCA\Encryption\Settings\Admin;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\AppFramework\Services\IInitialState;
use OCP\IAppConfig;
use OCP\IConfig;
use OCP\IL10N;
use OCP\ISession;
@ -29,16 +31,20 @@ class AdminTest extends TestCase {
protected IConfig&MockObject $config;
protected IUserManager&MockObject $userManager;
protected ISession&MockObject $session;
protected IInitialState&MockObject $initialState;
protected IAppConfig&MockObject $appConfig;
protected function setUp(): void {
parent::setUp();
$this->l = $this->getMockBuilder(IL10N::class)->getMock();
$this->logger = $this->getMockBuilder(LoggerInterface::class)->getMock();
$this->userSession = $this->getMockBuilder(IUserSession::class)->getMock();
$this->config = $this->getMockBuilder(IConfig::class)->getMock();
$this->userManager = $this->getMockBuilder(IUserManager::class)->getMock();
$this->session = $this->getMockBuilder(ISession::class)->getMock();
$this->l = $this->createMock(IL10N::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->config = $this->createMock(IConfig::class);
$this->userManager = $this->createMock(IUserManager::class);
$this->session = $this->createMock(ISession::class);
$this->initialState = $this->createMock(IInitialState::class);
$this->appConfig = $this->createMock(IAppConfig::class);
$this->admin = new Admin(
$this->l,
@ -46,11 +52,18 @@ class AdminTest extends TestCase {
$this->userSession,
$this->config,
$this->userManager,
$this->session
$this->session,
$this->initialState,
$this->appConfig,
);
}
public function testGetForm(): void {
$this->appConfig
->method('getValueBool')
->willReturnMap([
['encryption', 'recoveryAdminEnabled', true]
]);
$this->config
->method('getAppValue')
->willReturnCallback(function ($app, $key, $default) {
@ -62,13 +75,17 @@ class AdminTest extends TestCase {
}
return $default;
});
$params = [
'recoveryEnabled' => '1',
'initStatus' => '0',
'encryptHomeStorage' => true,
'masterKeyEnabled' => true
];
$expected = new TemplateResponse('encryption', 'settings-admin', $params, '');
$this->initialState
->expects(self::once())
->method('provideInitialState')
->with('adminSettings', [
'recoveryEnabled' => true,
'initStatus' => '0',
'encryptHomeStorage' => true,
'masterKeyEnabled' => true
]);
$expected = new TemplateResponse('encryption', 'settings', renderAs: '');
$this->assertEquals($expected, $this->admin->getForm());
}

View file

@ -0,0 +1 @@
../../../apps/encryption

View file

@ -12,6 +12,11 @@ const modules = {
'settings-admin-example-content': resolve(import.meta.dirname, 'apps/dav/src', 'settings-admin-example-content.ts'),
'settings-personal-availability': resolve(import.meta.dirname, 'apps/dav/src', 'settings-personal-availability.ts'),
},
encryption: {
encryption: resolve(import.meta.dirname, 'apps/encryption/src', 'encryption.ts'),
settings_admin: resolve(import.meta.dirname, 'apps/encryption/src', 'settings-admin.ts'),
settings_personal: resolve(import.meta.dirname, 'apps/encryption/src', 'settings-personal.ts'),
},
federation: {
'settings-admin': resolve(import.meta.dirname, 'apps/federation/src', 'settings-admin.ts'),
},

View file

@ -1135,9 +1135,6 @@
</InternalMethod>
</file>
<file src="apps/encryption/lib/Settings/Admin.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
<InternalClass>
<code><![CDATA[new View()]]></code>
</InternalClass>
@ -1145,11 +1142,6 @@
<code><![CDATA[new View()]]></code>
</InternalMethod>
</file>
<file src="apps/encryption/lib/Settings/Personal.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>
</DeprecatedMethod>
</file>
<file src="apps/encryption/lib/Util.php">
<DeprecatedMethod>
<code><![CDATA[getAppValue]]></code>