Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4ea80cf5e | ||
|
|
feacb58cd1 | ||
|
|
1de30c0c38 | ||
|
|
4c30054a2d | ||
|
|
0b9e06c28d | ||
|
|
37e4a99ba6 | ||
|
|
7078eb7482 | ||
|
|
c2ec97d641 | ||
|
|
2a2a5ffb66 | ||
|
|
b84ef57d58 | ||
|
|
14b066d3cd | ||
|
|
2b5e167b07 | ||
|
|
c9547cbdb8 | ||
|
|
89a76148b4 | ||
|
|
81fd472bb3 | ||
|
|
4ba9c63eb4 | ||
|
|
9e20c5a3e3 | ||
|
|
20e0774df2 | ||
|
|
603244aa6a | ||
|
|
1cc30934c7 | ||
|
|
053f26cd02 | ||
|
|
cc82a088a9 | ||
|
|
e30e384cec | ||
|
|
33691a6507 | ||
|
|
83917f98f5 | ||
|
|
1fe5a61e52 | ||
|
|
dab9bcb9db | ||
|
|
b2ca6206cc | ||
|
|
918c2f8587 | ||
|
|
8f851164d6 | ||
|
|
d215d1998f | ||
|
|
75e3256497 | ||
|
|
58f5a17a83 | ||
|
|
e7c3d797be | ||
|
|
34abd5b8f5 | ||
|
|
8b138d14bb | ||
|
|
e23abd69b5 | ||
|
|
9c16ad456d | ||
|
|
4b851afc6a | ||
|
|
f333148afa | ||
|
|
8d0160a3e7 | ||
|
|
4a02e988c1 | ||
|
|
134472cd4b | ||
|
|
b40a10da7b | ||
|
|
25f5b33d17 | ||
|
|
f57c39bba2 | ||
|
|
99b234eca8 | ||
|
|
9c3c8cc9d1 | ||
|
|
b08e3a5128 | ||
|
|
e48cdc85f7 | ||
|
|
a5da68a7f9 | ||
|
|
7d594ab0d3 | ||
|
|
9ed3d668ee | ||
|
|
0da0a5e027 | ||
|
|
28eb0b65f4 | ||
|
|
1d29572ecd | ||
|
|
07e8d242d1 | ||
|
|
1586a286d8 | ||
|
|
4859059eba | ||
|
|
f2949c1836 | ||
|
|
cd6acfb91d | ||
|
|
2d5dc9a6aa | ||
|
|
87e7da2aff | ||
|
|
461eb38d88 | ||
|
|
ba0bb62fa3 | ||
|
|
1ca62dd38e | ||
|
|
1b1328c601 | ||
|
|
9633036887 | ||
|
|
e3d76ea9f4 | ||
|
|
d7212e69b5 | ||
|
|
ead58bd6f6 | ||
|
|
fbe13b75df | ||
|
|
6085a8231f | ||
|
|
e15cd190b3 | ||
|
|
3d55423deb | ||
|
|
f62df52c1d | ||
|
|
7829f94ac4 | ||
|
|
e9d42b9cdd | ||
|
|
1f05a8d92a | ||
|
|
69ae2b2997 | ||
|
|
b86e47de02 | ||
|
|
ea7647f43c | ||
|
|
ae7df52780 | ||
|
|
bc5737b9b0 | ||
|
|
d0745d1914 | ||
|
|
2b4c926a70 | ||
|
|
d922c1c80f | ||
|
|
67500a7689 | ||
|
|
1c8aa7440c | ||
|
|
d128dbec4c | ||
|
|
676cb8810b | ||
|
|
189e3d5599 | ||
|
|
009d0b39f9 | ||
|
|
81a8aa7c3d | ||
|
|
6c6761d14b | ||
|
|
7d2e3df929 | ||
|
|
f334c696cb | ||
|
|
db974d72d5 | ||
|
|
c15e342bb8 | ||
|
|
dc1449c7b6 | ||
|
|
0b305caf58 | ||
|
|
8f1f3645b2 | ||
|
|
0079162efe | ||
|
|
7afec06d4c | ||
|
|
29f85a8fd2 | ||
|
|
6e0dc24eca | ||
|
|
cee1180aa7 | ||
|
|
6db2b55e63 | ||
|
|
a3c038781f | ||
|
|
59c9e5397e | ||
|
|
a4c253b9a9 | ||
|
|
f12b4faf34 | ||
|
|
ff49759f42 | ||
|
|
01d0c738bc | ||
|
|
d57554a48c | ||
|
|
b16b57f38b | ||
|
|
12177446a2 | ||
|
|
61b15db958 | ||
|
|
349e8f5139 | ||
|
|
f30482808b | ||
|
|
79cdecdff6 | ||
|
|
336dee53cd | ||
|
|
77022bbd7f | ||
|
|
df96df776d | ||
|
|
67bc803859 | ||
|
|
8ef56c6d91 | ||
|
|
d377d1e1cf | ||
|
|
4209e4d90d | ||
|
|
83c85d02ee | ||
|
|
866b72d029 | ||
|
|
4bc0f44789 | ||
|
|
99c55cb22f | ||
|
|
74c73c695f | ||
|
|
b972891337 | ||
|
|
57515d54db | ||
|
|
0ff8dcc8e9 | ||
|
|
38efa6a2ba | ||
|
|
6306625184 | ||
|
|
1803ab303f | ||
|
|
e72dd7c845 | ||
|
|
87288caeb9 | ||
|
|
79b519e462 | ||
|
|
5f8ea2aecc | ||
|
|
94bc52a0c3 | ||
|
|
fed51bdcd9 | ||
|
|
5fc9689645 | ||
|
|
7c6a783fc8 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@ installer/src/certs/server.key
|
||||
# vim swap files
|
||||
*.swp
|
||||
|
||||
.cursor
|
||||
|
||||
|
||||
62
CHANGES
62
CHANGES
@@ -3162,3 +3162,65 @@
|
||||
* backup: show integrity column for dependsOn backups
|
||||
* integrity: show log link
|
||||
* syncer: fix bug with a file and dir having same prefix
|
||||
|
||||
[9.1.3]
|
||||
* Remove 'Dashboard' from dashboard page title
|
||||
* integrity: skip check of backups with no integrity info
|
||||
* backupintegrity: add percent progress
|
||||
* apps: fix acl display
|
||||
|
||||
[9.1.4]
|
||||
* services: lazy start services / on demand services
|
||||
* restore: fix restore of trusted ips and blocklist
|
||||
* dashboard: wait for dashboard reload when version has changed
|
||||
* graphite: fix aggregation of block/network read/write
|
||||
* Workaround chrome quirks on file drop handling
|
||||
* notifications: add empty text, progress bar and inifinite scroll
|
||||
* rsync: throttle log messages during download
|
||||
* backup logs: make them much terse and concise
|
||||
* oidc: implement Device Authorization Grant
|
||||
* operator: fix viewing of backup progress and logs
|
||||
* notification: automatic app update failure notification
|
||||
* backup sites: identify conflicting site locations
|
||||
* update: add policy to update apps and platform separately
|
||||
* passkey: fix issue where passkeys were lost on restart
|
||||
* passkey: implement passwordless login
|
||||
* oidcserver: fix jwks_rsaonly response
|
||||
|
||||
[9.1.5]
|
||||
* services: lazy start services / on demand services
|
||||
* restore: fix restore of trusted ips and blocklist
|
||||
* dashboard: wait for dashboard reload when version has changed
|
||||
* graphite: fix aggregation of block/network read/write
|
||||
* Workaround chrome quirks on file drop handling
|
||||
* notifications: add empty text, progress bar and inifinite scroll
|
||||
* rsync: throttle log messages during download
|
||||
* backup logs: make them much terse and concise
|
||||
* oidc: implement Device Authorization Grant
|
||||
* operator: fix viewing of backup progress and logs
|
||||
* notification: automatic app update failure notification
|
||||
* backup sites: identify conflicting site locations
|
||||
* update: add policy to update apps and platform separately
|
||||
* passkey: fix issue where passkeys were lost on restart
|
||||
* passkey: implement passwordless login
|
||||
* oidcserver: fix jwks_rsaonly response
|
||||
|
||||
[9.1.6]
|
||||
* apps: fix wrong disabled state for devices config
|
||||
* notifications: send email when manual platform and app update required
|
||||
* source install: support dockerfileName and build options
|
||||
* source install: persist buildConfig so restore, import, clone work correctly
|
||||
* search for matches in app links labels for apps view filter
|
||||
* restore: prune portBindings whose tcpPorts/udpPorts no longer exist
|
||||
* location: fix duplication of port bindings on submit
|
||||
* Update translations
|
||||
* location: show what DNS is being overwritten in location UI
|
||||
* backup site: remove the local disk provider
|
||||
* mail: update haraka to 3.1.4, tika to 3.3.0
|
||||
* solr: dynamically allocate java heap based on container mem
|
||||
|
||||
[9.2.0]
|
||||
* apppasswords: generate easier to type passwords
|
||||
* logs: escape and unescape new lines
|
||||
* backups/volumes: rename 'mountpoint' to 'User-managed Mount Point'
|
||||
* mail: listen on the bridge IP
|
||||
|
||||
27
box.js
27
box.js
@@ -4,15 +4,16 @@ import constants from './src/constants.js';
|
||||
import fs from 'node:fs';
|
||||
import ldapServer from './src/ldapserver.js';
|
||||
import net from 'node:net';
|
||||
import authServer from './src/authserver.js';
|
||||
import oidcServer from './src/oidcserver.js';
|
||||
import paths from './src/paths.js';
|
||||
import proxyAuth from './src/proxyauth.js';
|
||||
import safe from 'safetydance';
|
||||
import safe from '@cloudron/safetydance';
|
||||
import server from './src/server.js';
|
||||
import directoryServer from './src/directoryserver.js';
|
||||
import debugModule from 'debug';
|
||||
import logger from './src/logger.js';
|
||||
|
||||
const debug = debugModule('box:box');
|
||||
const { log } = logger('box');
|
||||
|
||||
let logFd;
|
||||
|
||||
@@ -37,8 +38,10 @@ async function setupNetworking() {
|
||||
function exitSync(status) {
|
||||
const ts = new Date().toISOString();
|
||||
if (status.message) fs.write(logFd, `${ts} ${status.message}\n`, function () {});
|
||||
const msg = status.error.stack.replace(/\n/g, `\n${ts} `); // prefix each line with ts
|
||||
if (status.error) fs.write(logFd, `${ts} ${msg}\n`, function () {});
|
||||
if (status.error) {
|
||||
const escapedStack = status.error.stack.replace(/\\/g, '\\\\').replace(/\n/g, '\\n');
|
||||
fs.write(logFd, `${ts} ${escapedStack}\n`, function () {});
|
||||
}
|
||||
fs.fsyncSync(logFd);
|
||||
fs.closeSync(logFd);
|
||||
process.exit(status.code);
|
||||
@@ -59,39 +62,41 @@ const [error] = await safe(startServers());
|
||||
if (error) exitSync({ error, code: 1, message: 'Error starting servers' });
|
||||
|
||||
process.on('SIGHUP', async function () {
|
||||
debug('Received SIGHUP. Re-reading configs.');
|
||||
log('Received SIGHUP. Re-reading configs.');
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.checkCertificate();
|
||||
});
|
||||
|
||||
process.on('SIGINT', async function () {
|
||||
debug('Received SIGINT. Shutting down.');
|
||||
log('Received SIGINT. Shutting down.');
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async function () {
|
||||
debug('Received SIGTERM. Shutting down.');
|
||||
log('Received SIGTERM. Shutting down.');
|
||||
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldapServer.stop();
|
||||
await oidcServer.stop();
|
||||
await authServer.stop();
|
||||
|
||||
setTimeout(() => {
|
||||
debug('Shutdown complete');
|
||||
log('Shutdown complete');
|
||||
process.exit();
|
||||
}, 2000); // need to wait for the task processes to die
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => exitSync({ error, code: 1, message: 'From uncaughtException handler.' }));
|
||||
process.on('uncaughtException', (uncaughtError) => exitSync({ error: uncaughtError, code: 1, message: 'From uncaughtException handler.' }));
|
||||
|
||||
@@ -2,19 +2,59 @@
|
||||
|
||||
<script>
|
||||
|
||||
const tmp = window.location.hash.slice(1).split('&');
|
||||
(async function () {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
|
||||
// FIXME: implicit flow (response_type=code token) results in access_token query param. this is not secure
|
||||
tmp.forEach(function (pair) {
|
||||
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
|
||||
});
|
||||
if (!code) {
|
||||
console.error('No authorization code in callback URL');
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
|
||||
const clientId = sessionStorage.getItem('pkce_client_id') || 'cid-webadmin';
|
||||
const apiOrigin = sessionStorage.getItem('pkce_api_origin') || '';
|
||||
|
||||
window.location.replace(redirectTo); // this removes us from history
|
||||
sessionStorage.removeItem('pkce_code_verifier');
|
||||
sessionStorage.removeItem('pkce_client_id');
|
||||
sessionStorage.removeItem('pkce_api_origin');
|
||||
|
||||
try {
|
||||
const response = await fetch(apiOrigin + '/openid/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
client_id: clientId,
|
||||
redirect_uri: window.location.origin + '/authcallback.html',
|
||||
code_verifier: codeVerifier
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.access_token) {
|
||||
console.error('Token exchange failed', data);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.token = data.access_token;
|
||||
} catch (e) {
|
||||
console.error('Token exchange error', e);
|
||||
window.location.replace('/');
|
||||
return;
|
||||
}
|
||||
|
||||
let redirectTo = '/';
|
||||
if (localStorage.getItem('redirectToHash')) {
|
||||
redirectTo += localStorage.getItem('redirectToHash');
|
||||
localStorage.removeItem('redirectToHash');
|
||||
}
|
||||
|
||||
window.location.replace(redirectTo);
|
||||
})();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,7 @@ export default [
|
||||
"vue/no-reserved-component-names": "off",
|
||||
"vue/multi-word-component-names": "off",
|
||||
"vue/no-undef-components": "error",
|
||||
'vue/no-root-v-if': "error",
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title><%= name %> Dashboard</title>
|
||||
<title><%= name %></title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
16
dashboard/oidc_device_confirm.html
Normal file
16
dashboard/oidc_device_confirm.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Confirm</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name, clientName, userCode, form }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdeviceconfirm.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
dashboard/oidc_device_input.html
Normal file
16
dashboard/oidc_device_input.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Device Sign-in</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name, message, form }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdeviceinput.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
dashboard/oidc_device_success.html
Normal file
16
dashboard/oidc_device_success.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Device Success</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdevicesuccess.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,13 +4,7 @@
|
||||
<title><%= name %> OpenID Error</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
errorMessage: errorMessage,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
window.cloudron = <%- JSON.stringify({ iconUrl, name, errorMessage, footer, language }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
|
||||
passkeyLoginUrl: passkeyLoginUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
|
||||
4292
dashboard/package-lock.json
generated
4292
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,11 +7,11 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@cloudron/pankow": "^4.1.3",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.10",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
@@ -19,15 +19,15 @@
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-vue": "^10.7.0",
|
||||
"marked": "^17.0.3",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"marked": "^18.0.0",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.29",
|
||||
"vue-i18n": "^11.2.8",
|
||||
"vue-router": "^5.0.3"
|
||||
"moment-timezone": "^0.6.1",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-i18n": "^11.3.2",
|
||||
"vue-router": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"configure": "Nakonfigurovat",
|
||||
"restart": "Restartovat",
|
||||
"reset": "Zresetovat",
|
||||
"loadMore": "Načíst více"
|
||||
"loadMore": "Načíst více",
|
||||
"setup": "Nastavit",
|
||||
"disable": "Zakázat"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Restart serveru",
|
||||
@@ -348,8 +350,6 @@
|
||||
"allowedIpRanges": "Povolený rozsah IP adres"
|
||||
},
|
||||
"changePasswordAction": "Změnit heslo",
|
||||
"disable2FAAction": "Zakázat 2FA",
|
||||
"enable2FAAction": "Povolit 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail odeslán na {{ email }}"
|
||||
},
|
||||
@@ -363,9 +363,23 @@
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Dvoufaktorová autentizace",
|
||||
"disabled": "Zakázáno",
|
||||
"totpEnabled": "Použít časově omezené jednorázové heslo (TOTP)",
|
||||
"passkeyEnabled": "Použít passkey"
|
||||
"totpEnabled": "Povoleno",
|
||||
"passkeyEnabled": "Povoleno",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "nenastaveno",
|
||||
"enablePasskey": {
|
||||
"title": "Povolit passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Povolit TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Zakázat TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Zakázat passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -650,18 +664,17 @@
|
||||
"updateAvailableAction": "Aktualizace k dispozici",
|
||||
"stopUpdateAction": "Zastavit aktualizaci",
|
||||
"disabled": "Zakázáno",
|
||||
"schedule": "Naplánovat aktualizace",
|
||||
"onLatest": "poslední",
|
||||
"description": "Aktualizace platformy a aplikací se aplikují v nastavený čas podle <a href=\"/#/system-settings\">časové zóny systému</a>."
|
||||
"description": "Aktualizace se aplikují v nastavený čas podle <a href=\"/#/system-settings\">časové zóny systému</a>.",
|
||||
"config": "Automatické aktualizace",
|
||||
"platformAndApps": "Platforma a aplikace",
|
||||
"appsOnly": "Pouze aplikace"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"disableCheckbox": "Zakázat automatické aktualizace",
|
||||
"enableCheckbox": "Povolit automatické aktualizace",
|
||||
"selectOne": "Vyberte alespoň jeden den a čas",
|
||||
"days": "Dny",
|
||||
"hours": "Hodiny",
|
||||
"description": "Nastavte dny a časy pro automatické aktualizace platformy a aplikací. Ujistěte se, že se tento plán nepřekrývá s plány zálohování.",
|
||||
"title": "Konfigurovat čas automatických aktualizací"
|
||||
"description": "Nastavte dny a časy pro automatické aktualizace platformy a aplikací. Ujistěte se, že se tento plán nepřekrývá s plány zálohování."
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Aktualizovat Cloudron",
|
||||
@@ -680,6 +693,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Poskytovatel docker registrů",
|
||||
"providerOther": "Jiné"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Konfigurovat automatické aktualizace",
|
||||
"policy": "Politika",
|
||||
"policyDescription": "Vyberte, co se bude automaticky aktualizovat",
|
||||
"days": "Dnů/y",
|
||||
"hours": "Hodin/y",
|
||||
"schedule": "Plány záloh"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
@@ -890,7 +911,9 @@
|
||||
"appDown": "Aplikace je mimo provoz",
|
||||
"rebootRequired": "Vyžadován restart serveru",
|
||||
"cloudronUpdateFailed": "Aktualizace Cloudronu selhala",
|
||||
"diskSpace": "Nedostatek místa na disku"
|
||||
"diskSpace": "Nedostatek místa na disku",
|
||||
"appAutoUpdateFailed": "Automatická aktualizace aplikace selhala",
|
||||
"manualUpdateRequired": "Platforma nebo aplikace vyžaduje ruční aktualizaci"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "Na váš primární e-mail bude odeslán e-mail souhrn těchto vybraných událostí."
|
||||
@@ -947,10 +970,10 @@
|
||||
"noRedirections": "Žádné přesměrované domény",
|
||||
"addRedirectionAction": "Přidat přesměrování",
|
||||
"saveAction": "Uložit",
|
||||
"dnsoverwrite": "Některé DNS záznamy již existují. Potvrďte přepsání.",
|
||||
"aliases": "Aliasy",
|
||||
"addAliasAction": "Přidat alias",
|
||||
"noAliases": "Žádné aliasy pro domény"
|
||||
"noAliases": "Žádné aliasy pro domény",
|
||||
"overwriteDns": "Přepsat existující DNS záznamy pro {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -1600,7 +1623,9 @@
|
||||
"errorIncorrect2FAToken": "Token 2FA je neplatný",
|
||||
"errorInternal": "Interní chyba, zkuste akci opakovat později",
|
||||
"loginAction": "Přihlásit se",
|
||||
"usePasskeyAction": "Použít passkey"
|
||||
"usePasskeyAction": "Použít passkey",
|
||||
"passkeyAction": "Přihlásit se přes passkey",
|
||||
"errorPasskeyFailed": "Přihlášení pomocí passkey selhalo"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Reset hesla",
|
||||
|
||||
@@ -296,8 +296,6 @@
|
||||
"password": "Adgangskode til bekræftelse"
|
||||
},
|
||||
"changePasswordAction": "Ændre adgangskode",
|
||||
"disable2FAAction": "Deaktivere 2FA",
|
||||
"enable2FAAction": "Aktiver 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail sendt til {{ email }}"
|
||||
}
|
||||
@@ -555,11 +553,8 @@
|
||||
"updateScheduleDialog": {
|
||||
"selectOne": "Vælg mindst én dag og ét tidspunkt",
|
||||
"description": "Vælg de dage og timer, hvor Cloudron vil anvende automatiske platforms- og appopdateringer. Pas på, at denne tidsplan ikke overlapper med <a href=\"/#/backups\">backup-tidsplanen</a>.",
|
||||
"title": "Konfigurer tidsplan for automatisk opdatering",
|
||||
"disableCheckbox": "Deaktivere automatiske opdateringer",
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer",
|
||||
"days": "Dage",
|
||||
"hours": "Timer"
|
||||
"enableCheckbox": "Aktivere automatiske opdateringer"
|
||||
},
|
||||
"updateDialog": {
|
||||
"unstableWarning": "Denne opdatering er en præudgave og betragtes ikke som stabil endnu. Opdatering sker på egen risiko.",
|
||||
|
||||
@@ -134,7 +134,6 @@
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"description": "Plattform und App-Aktualisierungen werden automatisch, basierend auf dem Zeitplan in der <a href=\"/#/system-settings\">Systemzeitzone</a> ausgeführt.",
|
||||
"disabled": "Deaktiviert",
|
||||
"schedule": "Aktualisierungszeitplan",
|
||||
"onLatest": "neueste"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
@@ -154,13 +153,10 @@
|
||||
}
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"hours": "Stunden",
|
||||
"disableCheckbox": "Automatische Aktualisierung deaktivieren",
|
||||
"enableCheckbox": "Automatische Aktualisierung aktivieren",
|
||||
"selectOne": "Mindestens einen Tag und eine Uhrzeit wählen",
|
||||
"days": "Tage",
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden.",
|
||||
"title": "Automatische Aktualisierung konfigurieren"
|
||||
"description": "Tage und Stunden auswählen, an denen Cloudron das System und die Anwendungen aktualisieren soll. Der Zeitplan soll sich nicht mit dem Zeitplan für Datensicherungen überschneiden."
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Dient dazu, Datensicherungen und Updates zu planen. UI-Zeitstempel folgen immer der Zeitzone des Browsers.",
|
||||
@@ -370,8 +366,6 @@
|
||||
"description": "App-Passwörter sind eine Sicherheitsmaßnahme zum Schutz des Cloudron-User-Kontos. Sobald eingerichtet, kann die Anmeldung (zusätzlich) mit dem Usernamen und dem hier angezeigtem Passwort erfolgen. Hinweis: sinnvoll bei nicht vertrauenswürdigen mobilen Anwendungen oder Desktop-Clients.",
|
||||
"title": "App-Passwörter"
|
||||
},
|
||||
"enable2FAAction": "2FA aktivieren",
|
||||
"disable2FAAction": "2FA deaktivieren",
|
||||
"changePasswordAction": "Passwort ändern",
|
||||
"createApiToken": {
|
||||
"copyNow": "API-Token kopieren. Hinweis: keine erneute Anzeige des API-Tokens.",
|
||||
@@ -1420,8 +1414,7 @@
|
||||
"addRedirectionAction": "Eine Weiterleitung hinzufügen",
|
||||
"noAliases": "Keine Aliasse",
|
||||
"addAliasAction": "Alias hinzufügen",
|
||||
"aliases": "Aliasse",
|
||||
"dnsoverwrite": "Einige DNS-Einträge existieren bereits. Mit dem Überschreiben einverstanden."
|
||||
"aliases": "Aliasse"
|
||||
},
|
||||
"updateDialog": {
|
||||
"subscriptionExpired": "Das Cloudron-Abonnement ist abgelaufen. Bitte ein Abonnement einrichten, um die Anwendung zu aktualisieren.",
|
||||
|
||||
@@ -48,7 +48,9 @@
|
||||
"configure": "Configure",
|
||||
"restart": "Restart",
|
||||
"reset": "Reset",
|
||||
"loadMore": "Load more"
|
||||
"loadMore": "Load more",
|
||||
"setup": "Set up",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Reboot Server",
|
||||
@@ -348,8 +350,6 @@
|
||||
"allowedIpRanges": "Allowed IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Change password",
|
||||
"disable2FAAction": "Disable 2FA",
|
||||
"enable2FAAction": "Enable 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email sent to {{ email }}"
|
||||
},
|
||||
@@ -363,9 +363,23 @@
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Two-factor authentication",
|
||||
"disabled": "Disabled",
|
||||
"totpEnabled": "Using time-based one-time password (TOTP)",
|
||||
"passkeyEnabled": "Using passkey"
|
||||
"totpEnabled": "Enabled",
|
||||
"passkeyEnabled": "Enabled",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Not set",
|
||||
"enablePasskey": {
|
||||
"title": "Enable passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Enable TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Disable TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Disable Passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -707,17 +721,16 @@
|
||||
"updateAvailableAction": "Update available",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Update schedule",
|
||||
"description": "Platform and app updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest"
|
||||
"description": "Updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest",
|
||||
"config": "Automatic updates",
|
||||
"appsOnly": "Apps only",
|
||||
"platformAndApps": "Platform & apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Configure Automatic Update Schedule",
|
||||
"disableCheckbox": "Disable automatic updates",
|
||||
"enableCheckbox": "Enable automatic updates",
|
||||
"selectOne": "Select at least one day and time",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"description": "Set the days and times for automatic platform and app updates. Ensure this schedule doesn’t overlap with backup schedules."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -737,6 +750,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Docker registry provider",
|
||||
"providerOther": "Other"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Configure Automatic Updates",
|
||||
"policy": "Policy",
|
||||
"policyDescription": "Choose what gets updated automatically",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"schedule": "Schedule"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -890,7 +911,9 @@
|
||||
"appDown": "App is down",
|
||||
"rebootRequired": "Server reboot required",
|
||||
"cloudronUpdateFailed": "Cloudron update failed",
|
||||
"diskSpace": "Low disk space"
|
||||
"diskSpace": "Low disk space",
|
||||
"appAutoUpdateFailed": "App automatic update failed",
|
||||
"manualUpdateRequired": "Platform or app requires manual update"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "An email will be sent for the selected events to your primary email."
|
||||
@@ -1207,7 +1230,7 @@
|
||||
"aliases": "Aliases",
|
||||
"addAliasAction": "Add an alias",
|
||||
"noAliases": "No alias domains",
|
||||
"dnsoverwrite": "Some DNS records already exist. Agree to overwrite."
|
||||
"overwriteDns": "Overwrite existing DNS records of {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -1520,7 +1543,9 @@
|
||||
"errorIncorrect2FAToken": "2FA token is invalid",
|
||||
"errorInternal": "Internal error, try again later",
|
||||
"loginAction": "Log in",
|
||||
"usePasskeyAction": "Use passkey"
|
||||
"usePasskeyAction": "Use passkey",
|
||||
"errorPasskeyFailed": "Failed to login with passkey",
|
||||
"passkeyAction": "Log in with a passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Password reset",
|
||||
@@ -1609,7 +1634,8 @@
|
||||
"editVolumeDialog": {
|
||||
"title": "Edit Volume"
|
||||
},
|
||||
"emptyPlaceholder": "No volumes"
|
||||
"emptyPlaceholder": "No volumes",
|
||||
"mountPointDescription": "The mount point has to be set up manually. See <a href=\"{{ docsLink }}\" target=\"_blank\">docs</a>."
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"subject": "[<%= cloudron %>] New login on your account",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,8 @@
|
||||
"email": "Se connecter avec une adresse email",
|
||||
"sso": "Se connecter avec vos identifiants Cloudron",
|
||||
"openid": "Se connecter avec Cloudron OpenID"
|
||||
}
|
||||
},
|
||||
"noMatchesPlaceholder": "Aucune application correspondante"
|
||||
},
|
||||
"main": {
|
||||
"offline": "Cloudron est hors ligne. Reconnexion…",
|
||||
@@ -26,14 +27,26 @@
|
||||
"save": "Sauvegarder",
|
||||
"no": "Non",
|
||||
"yes": "Oui",
|
||||
"delete": "Supprimer"
|
||||
"delete": "Supprimer",
|
||||
"edit": "Editer",
|
||||
"done": "Terminer"
|
||||
},
|
||||
"username": "Nom d'utilisateur",
|
||||
"actions": "Actions",
|
||||
"displayName": "Nom affiché",
|
||||
"action": {
|
||||
"logs": "Journaux",
|
||||
"reboot": "Redémarrer"
|
||||
"reboot": "Redémarrer",
|
||||
"remove": "Supprimer",
|
||||
"edit": "Editer",
|
||||
"add": "Ajouter",
|
||||
"next": "Suivant",
|
||||
"configure": "Configurer",
|
||||
"restart": "Redémarrer",
|
||||
"reset": "Réinitialiser",
|
||||
"loadMore": "Charger plus",
|
||||
"setup": "Installer",
|
||||
"disable": "Désactiver"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Redémarrer maintenant",
|
||||
@@ -47,9 +60,20 @@
|
||||
},
|
||||
"statusEnabled": "Activé",
|
||||
"navbar": {
|
||||
"users": "Utilisateurs"
|
||||
"users": "Utilisateurs",
|
||||
"groups": "Groupes"
|
||||
},
|
||||
"loadingPlaceholder": "Chargement"
|
||||
"loadingPlaceholder": "Chargement",
|
||||
"table": {
|
||||
"version": "Version",
|
||||
"created": "Créé"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Réduire la barre latérale"
|
||||
},
|
||||
"platform": {
|
||||
"startupFailed": "Échec du démarrage de la plateforme"
|
||||
}
|
||||
},
|
||||
"users": {
|
||||
"users": {
|
||||
@@ -64,17 +88,22 @@
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe",
|
||||
"setGhostTooltip": "Emprunter l'identité",
|
||||
"invitationTooltip": "Inviter",
|
||||
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail"
|
||||
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail",
|
||||
"noMatchesPlaceholder": "Aucun utilisateur correspondant",
|
||||
"emptyPlaceholder": "Aucun utilisateur"
|
||||
},
|
||||
"groups": {
|
||||
"name": "Nom",
|
||||
"users": "Utilisateurs",
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe",
|
||||
"emptyPlaceholder": "Aucun groupe",
|
||||
"noMatchesPlaceholder": "Aucun groupe correspondant"
|
||||
},
|
||||
"settings": {
|
||||
"allowProfileEditCheckbox": "Autoriser les utilisateurs à modifier leur nom et leur adresse email",
|
||||
"saveAction": "Enregistrer",
|
||||
"require2FACheckbox": "Demander aux utilisateurs une authentification à deux facteurs (2FA)"
|
||||
"require2FACheckbox": "Demander aux utilisateurs une authentification à deux facteurs (2FA)",
|
||||
"title": "Paramètres"
|
||||
},
|
||||
"externalLdap": {
|
||||
"configureAction": "Paramétrer",
|
||||
@@ -128,7 +157,8 @@
|
||||
"group": {
|
||||
"users": "Utilisateurs",
|
||||
"name": "Nom",
|
||||
"addGroupAction": "Ajouter un groupe"
|
||||
"addGroupAction": "Ajouter un groupe",
|
||||
"allowedApps": "Applications autorisées"
|
||||
},
|
||||
"deleteGroupDialog": {
|
||||
"title": "Supprimer le groupe {{ name }}",
|
||||
@@ -191,7 +221,14 @@
|
||||
"placeholder": "Adresse IP séparée par ligne ou sous-réseau",
|
||||
"label": "Accès restreint"
|
||||
},
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP"
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP",
|
||||
"enable": "Activer le serveur LDAP",
|
||||
"title": "Serveur LDAP",
|
||||
"enabled": "Activer le serveur LDAP"
|
||||
},
|
||||
"title": "Utilisateurs",
|
||||
"2FAResetDialog": {
|
||||
"title": "Réinitialiser l'authentification à deux facteurs de l'utilisateur"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -216,7 +253,8 @@
|
||||
"name": "Nom",
|
||||
"noPasswordsPlaceholder": "Aucun mot de passe d'application créé",
|
||||
"title": "Mots de passe d'application",
|
||||
"description": "Les mots de passe d'application sont une mesure de sécurité pour protéger votre compte utilisateur Cloudron. Si vous avez besoin d'accéder à une application Cloudron depuis une application mobile ou un client auquel vous ne faites pas confiance, vous pouvez vous connecter avec votre nom d'utilisateur et le mot de passe alternatif généré ici."
|
||||
"description": "Les mots de passe d'application sont une mesure de sécurité pour protéger votre compte utilisateur Cloudron. Si vous avez besoin d'accéder à une application Cloudron depuis une application mobile ou un client auquel vous ne faites pas confiance, vous pouvez vous connecter avec votre nom d'utilisateur et le mot de passe alternatif généré ici.",
|
||||
"expires": "Date d'expiration"
|
||||
},
|
||||
"changeEmail": {
|
||||
"title": "Modifier l'adresse email principale",
|
||||
@@ -228,7 +266,8 @@
|
||||
"app": "Application",
|
||||
"name": "Nom du mot de passe",
|
||||
"title": "Créer un mot de passe d'application",
|
||||
"description": "Utilisez le mot de passe suivant pour vous authentifier auprès de l'application :"
|
||||
"description": "Utilisez le mot de passe suivant pour vous authentifier auprès de l'application :",
|
||||
"expiresAt": "Date d'expiration"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"title": "Modifier l'adresse email de récupération du mot de passe"
|
||||
@@ -237,14 +276,20 @@
|
||||
"token": "Jeton",
|
||||
"title": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"enable": "Activer",
|
||||
"authenticatorAppDescription": "Scannez le code avec Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou une application d'authentification similaire."
|
||||
"authenticatorAppDescription": "Scannez le code avec Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou une application d'authentification similaire.",
|
||||
"mandatorySetup": "L'authentification à deux facteurs (2FA) est requise pour accéder au tableau de bord. Veuillez terminer la configuration pour continuer.",
|
||||
"passkeyOption": "Clé d'accès",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Installer une clé d'accès",
|
||||
"passkeyDescription": "Le navigateur vous invitera à créer une clé d'accès à l'aide des données biométriques de votre appareil ou d'un gestionnaire de mots de passe."
|
||||
},
|
||||
"createApiToken": {
|
||||
"name": "Nom du jeton API",
|
||||
"description": "Nouveau jeton API :",
|
||||
"title": "Créer un jeton API",
|
||||
"copyNow": "Veillez à copier le jeton API maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
|
||||
"access": "Accès API"
|
||||
"access": "Accès API",
|
||||
"allowedIpRanges": "Plage(s) d'adresses IP autorisées"
|
||||
},
|
||||
"changePasswordAction": "Modifier le mot de passe",
|
||||
"apiTokens": {
|
||||
@@ -256,17 +301,43 @@
|
||||
"lastUsed": "Dernière utilisation",
|
||||
"scope": "Portée",
|
||||
"readonly": "Lecture seule",
|
||||
"readwrite": "Lecture et écriture"
|
||||
"readwrite": "Lecture et écriture",
|
||||
"allowedIpRangesPlaceholder": "Adresses IP ou sous-réseaux séparés par des virgules",
|
||||
"allowedIpRanges": "Adresses IP autorisées"
|
||||
},
|
||||
"loginTokens": {
|
||||
"logoutAll": "Déconnecter de tous",
|
||||
"title": "Jetons de connexion",
|
||||
"description": "Vous avez {{ webadminTokens.length }} jeton(s) web actif(s) et {{ cliTokens.length }} jeton(s) CLI."
|
||||
},
|
||||
"disable2FAAction": "Désactiver l'authentification à deux facteurs (2FA)",
|
||||
"enable2FAAction": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email envoyé à {{ email }}"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Supprimer le jeton API"
|
||||
},
|
||||
"removeAppPassword": {
|
||||
"title": "Supprimer le mot de passe de l'application"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Authentification à deux facteurs",
|
||||
"totpEnabled": "Activé",
|
||||
"passkeyEnabled": "Activé",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Clé d'accès"
|
||||
},
|
||||
"notSet": "Non défini",
|
||||
"enablePasskey": {
|
||||
"title": "Activer la clé d'accès"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Activer le TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Désactiver le TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Désactiver la clé d'accès"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -278,7 +349,9 @@
|
||||
"days": "Jours",
|
||||
"hours": "Heures",
|
||||
"title": "Paramétrer la planification et la conservation des sauvegardes",
|
||||
"retentionPolicy": "Politique de conservation"
|
||||
"retentionPolicy": "Politique de conservation",
|
||||
"disable": "Désactiver les sauvegardes automatiques",
|
||||
"enable": "Activer les sauvegardes automatiques"
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Planification et conservation",
|
||||
@@ -326,13 +399,36 @@
|
||||
"port": "Port",
|
||||
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
|
||||
"chown": "Le système de fichiers distant prend en charge chown",
|
||||
"encryptFilenames": "Chiffré les nom de fichiers"
|
||||
"encryptFilenames": "Chiffré les nom de fichiers",
|
||||
"preserveAttributesLabel": "Conserver les attributs du fichier",
|
||||
"name": "Nom",
|
||||
"encryptionHint": "Indice pour le mot de passe de chiffrement",
|
||||
"usesEncryption": "La sauvegarde est chiffrée",
|
||||
"useForUpdates": "Enregistrer ici les sauvegardes des mises à jour automatiques",
|
||||
"backupContents": {
|
||||
"title": "Contenu de la sauvegarde",
|
||||
"description": "Choisissez les éléments à sauvegarder sur ce site.",
|
||||
"everything": "Tout",
|
||||
"excludeSelected": "Exclure les éléments sélectionnés",
|
||||
"includeOnlySelected": "N'inclure que les éléments sélectionnés"
|
||||
},
|
||||
"automaticUpdates": {
|
||||
"title": "Sauvegardes des mises à jour automatiques"
|
||||
},
|
||||
"useEncryption": "Chiffrer les sauvegardes",
|
||||
"regionHelperText": "Par défaut \"us-east-1\" si laissé vide",
|
||||
"prefixHelperText": "Les sauvegardes sont stockées dans ce sous-dossier"
|
||||
},
|
||||
"backupDetails": {
|
||||
"title": "Informations sur la sauvegarde",
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"size": "Taille",
|
||||
"duration": "Durée de la sauvegarde",
|
||||
"lastIntegrityCheck": "Dernier contrôle d'intégrité",
|
||||
"integrityNever": "Jamais",
|
||||
"integrityInProgress": "En cours"
|
||||
},
|
||||
"listing": {
|
||||
"title": "Liste",
|
||||
@@ -354,12 +450,45 @@
|
||||
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
|
||||
},
|
||||
"remotePath": "Chemin d'accès à distance"
|
||||
}
|
||||
},
|
||||
"archives": {
|
||||
"title": "Archive de l'application",
|
||||
"info": "Information"
|
||||
},
|
||||
"deleteArchiveDialog": {
|
||||
"title": "Supprimer l'archive"
|
||||
},
|
||||
"deleteArchive": {
|
||||
"deleteAction": "Supprimer"
|
||||
},
|
||||
"restoreArchiveDialog": {
|
||||
"title": "Restaurer à partir de l'archive",
|
||||
"restoreAction": "Restaurer",
|
||||
"restoreActionOverwrite": "Restaurer et écraser le DNS"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites"
|
||||
},
|
||||
"site": {
|
||||
"addDialog": {
|
||||
"title": "Ajouter un site de sauvegarde"
|
||||
}
|
||||
},
|
||||
"configAction": "Configuration",
|
||||
"contentAction": "Contenu",
|
||||
"configureContent": {
|
||||
"title": "Configurer le contenu de la sauvegarde"
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Chiffrement des fichiers et des noms de fichiers utilisé",
|
||||
"useFileEncryption": "Chiffrement des fichiers utilisé",
|
||||
"checkIntegrity": "Vérifier l'intégrité",
|
||||
"stopIntegrity": "Arrêter le contrôle d'intégrité"
|
||||
},
|
||||
"emails": {
|
||||
"title": "Messagerie",
|
||||
"changeDomainDialog": {
|
||||
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi."
|
||||
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi.",
|
||||
"setAction": "Définir l'emplacement"
|
||||
},
|
||||
"eventlog": {
|
||||
"details": "Détails",
|
||||
@@ -380,7 +509,9 @@
|
||||
"bounceInfo": "Notification d'email non distribué",
|
||||
"underQuotaInfo": "La boîte mail {{ mailbox }} est passée sous le quota de {{ quotaPercent }}%",
|
||||
"overQuotaInfo": "La boîte mail {{ mailbox }} est pleine à {{ quotaPercent }}%",
|
||||
"quota": "Quota de boîte mail"
|
||||
"quota": "Quota de boîte mail",
|
||||
"savedInfo": "Enregistré",
|
||||
"sentInfo": "Envoyé"
|
||||
},
|
||||
"title": "Journal des événements de la messagerie",
|
||||
"mailFrom": "De",
|
||||
@@ -402,7 +533,8 @@
|
||||
"title": "Domaines",
|
||||
"outbound": "Sortant uniquement",
|
||||
"stats": "{{ mailboxCount }} adresse(s) de messagerie / utilisation : {{ usage }}",
|
||||
"testEmailTooltip": "Envoyer un email test"
|
||||
"testEmailTooltip": "Envoyer un email test",
|
||||
"inbound": "Entrant et sortant"
|
||||
},
|
||||
"testMailDialog": {
|
||||
"title": "Envoyer un email test pour {{ domain }}",
|
||||
@@ -496,7 +628,12 @@
|
||||
"setupAction": "Créer un compte",
|
||||
"description": "Un compte Cloudron.io permet d'accéder à l'App Store et de gérer votre abonnement.",
|
||||
"title": "Compte Cloudron.io",
|
||||
"emailNotVerified": "Adresse email pas encore confirmée"
|
||||
"emailNotVerified": "Adresse email pas encore confirmée",
|
||||
"account": "Compte",
|
||||
"unlinkAction": "Dissocier le compte",
|
||||
"unlinkDialog": {
|
||||
"title": "Désassocier le compte Cloudron.io"
|
||||
}
|
||||
},
|
||||
"registryConfig": {
|
||||
"provider": "Fournisseur du registre Docker",
|
||||
@@ -517,22 +654,32 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Sélectionnez les jours et heures de lancement des mises à jour de la plateforme et des applications. Veillez à ne pas planifier les mises à jour au même moment que la <a href=\"/#/backups\">sauvegarde</a>.",
|
||||
"hours": "Heures",
|
||||
"days": "Jours",
|
||||
"selectOne": "Sélectionnez au moins un jour et une heure",
|
||||
"enableCheckbox": "Activer les mises à jour automatiques",
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques",
|
||||
"title": "Planification des mises à jour automatiques"
|
||||
"disableCheckbox": "Désactiver les mises à jour automatiques"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Interrompre la mise à jour",
|
||||
"updateAvailableAction": "Mise à jour disponible",
|
||||
"checkForUpdatesAction": "Rechercher les mises à jour disponibles",
|
||||
"title": "Mises à jour"
|
||||
"title": "Mises à jour",
|
||||
"disabled": "Désactivé",
|
||||
"onLatest": "dernier",
|
||||
"config": "Mises à jour automatiques",
|
||||
"appsOnly": "Applications uniquement",
|
||||
"platformAndApps": "Plateforme et applications"
|
||||
},
|
||||
"timezone": {
|
||||
"title": "Fuseau horaire",
|
||||
"description": "Le fuseau horaire défini actuellement est le suivant : <b>{{ timeZone }}</b>.\nCe paramètre est utilisé pour la planification des opérations de sauvegarde et de mise à jour."
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Configurer les mises à jour automatiques",
|
||||
"policy": "Stratégie",
|
||||
"policyDescription": "Choisissez ce qui doit être mis à jour automatiquement",
|
||||
"days": "Jours",
|
||||
"hours": "Heures",
|
||||
"schedule": "Planifier"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -543,7 +690,28 @@
|
||||
},
|
||||
"notifications": {
|
||||
"dismissTooltip": "Supprimer",
|
||||
"markAllAsRead": "Tout marquer comme lu"
|
||||
"markAllAsRead": "Tout marquer comme lu",
|
||||
"settings": {
|
||||
"title": "Paramètres de notification",
|
||||
"backupFailed": "Échec de la sauvegarde",
|
||||
"certificateRenewalFailed": "Échec du renouvellement du certificat",
|
||||
"appOutOfMemory": "L'application manque de mémoire",
|
||||
"appUp": "L'application est de nouveau disponible",
|
||||
"appDown": "L'application est hors service",
|
||||
"rebootRequired": "Un redémarrage du serveur est nécessaire",
|
||||
"cloudronUpdateFailed": "Échec de la mise à jour de Cloudron",
|
||||
"diskSpace": "Espace disque faible",
|
||||
"appAutoUpdateFailed": "Échec de la mise à jour automatique de l'application",
|
||||
"manualUpdateRequired": "La plateforme ou l'application nécessite une mise à jour manuelle"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "Un e-mail contenant les événements sélectionnés vous sera envoyé à votre adresse e-mail principale."
|
||||
},
|
||||
"title": "Notifications",
|
||||
"showAll": "Tout",
|
||||
"showUnread": "Non lu",
|
||||
"markUnread": "Marquer comme non lu",
|
||||
"markRead": "Marquer comme lu"
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
@@ -573,10 +741,14 @@
|
||||
"userManagementLeaveToApp": "Laisser la gestion des utilisateurs à l'application",
|
||||
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
|
||||
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé pour que le domaine de l'application puisse accéder à ce port",
|
||||
"portReadOnly": "lecture seule"
|
||||
"portReadOnly": "lecture seule",
|
||||
"ephemeralPortWarning": "L'utilisation de ports éphémères peut entraîner des conflits imprévisibles."
|
||||
},
|
||||
"unstable": "Instable",
|
||||
"searchPlaceholder": "Rechercher des solutions alternatives telles que GitHub, Dropbox, Slack, Trello…"
|
||||
"searchPlaceholder": "Rechercher des solutions alternatives telles que GitHub, Dropbox, Slack, Trello…",
|
||||
"action": {
|
||||
"addCustomApp": "Ajouter une application personnalisée"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"updatesTabTitle": "Mises à jour",
|
||||
@@ -586,7 +758,14 @@
|
||||
"lastUpdated": "Dernière mise à jour",
|
||||
"packageVersion": "Version du package",
|
||||
"appId": "ID de l'application",
|
||||
"description": "Nom et version de l'application"
|
||||
"description": "Nom et version de l'application",
|
||||
"installedAt": "Installé"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Mises à jour automatiques"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron vérifie automatiquement si des mises à jour sont disponibles pour les applications. Vous pouvez également les vérifier manuellement."
|
||||
}
|
||||
},
|
||||
"backupsTabTitle": "Sauvegardes",
|
||||
@@ -614,10 +793,27 @@
|
||||
"csp": {
|
||||
"saveAction": "Enregistrer",
|
||||
"description": "Le paramétrage de cette option écrasera tous les en-têtes CSP générés par l'application elle-même.",
|
||||
"title": "Politique de sécurité du contenu (CSP)"
|
||||
"title": "Politique de sécurité du contenu (CSP)",
|
||||
"insertCommonCsp": "Insérer un CSP standard",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Autoriser l'intégration",
|
||||
"sameOriginEmbedding": "Autoriser l'intégration (uniquement les sous-domaines)",
|
||||
"allowCdnAssets": "Autoriser les ressources CDN",
|
||||
"reportOnly": "Signaler les violations du CSP",
|
||||
"strictBaseline": "Référence stricte"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt"
|
||||
"title": "Robots.txt",
|
||||
"description": "Par défaut, les robots peuvent indexer cette application",
|
||||
"commonPattern": {
|
||||
"allowAll": "Tout autoriser (par défaut)",
|
||||
"disallowAll": "Tout interdire",
|
||||
"disallowCommonBots": "Bloquer les robots courants",
|
||||
"disallowAdminPaths": "Interdire les chemins d'accès à l'administration",
|
||||
"disallowApiPaths": "Interdire les chemins d'accès à l'API"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Insérer un fichier robots.txt standard"
|
||||
},
|
||||
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
|
||||
},
|
||||
@@ -647,18 +843,27 @@
|
||||
"operators": {
|
||||
"title": "Opérateurs",
|
||||
"description": "Les opérateurs peuvent configurer et assurer la maintenance de cette application."
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Définissez qui peut voir cette application sur le tableau de bord."
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"recovery": {
|
||||
"description": "Si l'application ne répond pas, essayez de redémarrer l'application. Si l'application redémarre sans arrêt à cause d'un plugin défectueux ou d'une anomalie de paramétrage, mettez l'application en mode récupération pour avoir accès à la console. \nSuivez les <a href=\"{{ docsLink }}\" target=\"_blank\">instructions suivantes</a> pour faire fonctionner l'application à nouveau.",
|
||||
"restartAction": "Redémarrer l'application",
|
||||
"title": "Récupération après un crash"
|
||||
"title": "Récupération après un crash",
|
||||
"disableAction": "Désactiver le mode de récupération",
|
||||
"enableAction": "Activer le mode de récupération"
|
||||
},
|
||||
"taskError": {
|
||||
"retryAction": "Relancer l'opération {{ task }}",
|
||||
"description": "Si une action de paramétrage, de mise à jour, de restauration ou de sauvegarde échoue, vous pouvez relancer l'opération.",
|
||||
"title": "Erreur de tâche"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Redémarrer",
|
||||
"description": "Si l'application ne répond pas, essayez de la redémarrer."
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
@@ -690,13 +895,18 @@
|
||||
"warning": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer une restauration.",
|
||||
"description": "Cette action entraînera la restauration de l'application à partir des données de {{ creationTime }}.",
|
||||
"title": "Restaurer {{ app }}",
|
||||
"restoreAction": "Restaurer"
|
||||
"restoreAction": "Restaurer",
|
||||
"cloneAction": "Cloner",
|
||||
"cloneActionOverwrite": "Cloner et écraser le DNS"
|
||||
},
|
||||
"importBackupDialog": {
|
||||
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
|
||||
"title": "Importer la sauvegarde",
|
||||
"importAction": "Importer",
|
||||
"remotePath": "Chemin de la sauvegarde"
|
||||
"remotePath": "Chemin de la sauvegarde",
|
||||
"provideBackupInfo": "Indiquez les informations de sauvegarde à partir desquelles effectuer la restauration, ou",
|
||||
"warning": "Toutes les données créées depuis la dernière sauvegarde seront définitivement perdues. Il est recommandé de créer une nouvelle sauvegarde avant l'importation.",
|
||||
"versionMustMatchInfo": "La sauvegarde doit avoir été créée à l'aide de la même version du package et des mêmes paramètres de contrôle d'accès que cette application."
|
||||
},
|
||||
"repairTabTitle": "Réparation",
|
||||
"uninstallDialog": {
|
||||
@@ -706,7 +916,10 @@
|
||||
},
|
||||
"appInfo": {
|
||||
"package": "Package",
|
||||
"openAction": "Ouvrir {{ app }}"
|
||||
"openAction": "Ouvrir {{ app }}",
|
||||
"checklist": "Liste de contrôle pour l'administrateur",
|
||||
"checklistShow": "Afficher la liste de contrôle",
|
||||
"checklistHide": "Cacher la liste de contrôle"
|
||||
},
|
||||
"firstTimeSetupAction": "Initialisation",
|
||||
"uninstall": {
|
||||
@@ -733,7 +946,8 @@
|
||||
"downloadConfigTooltip": "Télécharger le fichier de configuration de la sauvegarde",
|
||||
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
|
||||
"title": "Sauvegardes",
|
||||
"downloadBackupTooltip": "Télécharger la sauvegarde"
|
||||
"downloadBackupTooltip": "Télécharger la sauvegarde",
|
||||
"checkIntegrity": "Vérifier l'intégrité"
|
||||
}
|
||||
},
|
||||
"graphs": {
|
||||
@@ -742,7 +956,9 @@
|
||||
"7d": "7 jours",
|
||||
"24h": "24 heures",
|
||||
"12h": "12 heures",
|
||||
"6h": "6 heures"
|
||||
"6h": "6 heures",
|
||||
"live": "En direct",
|
||||
"1h": "1 heure"
|
||||
},
|
||||
"diskIOTotal": "total: lecture {{ read }} / écriture {{ write }}",
|
||||
"networkIOTotal": "total : entrant {{ inbound }} / sortant {{ outbound }}"
|
||||
@@ -757,6 +973,10 @@
|
||||
"description": "Taux limite d'utilisation du microprocesseur lorsque le système est très sollicité.",
|
||||
"title": "Utilisation du microprocesseur",
|
||||
"setAction": "Valider"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Appareils",
|
||||
"description": "Liste des appareils connectés à l'application, séparés par des virgules"
|
||||
}
|
||||
},
|
||||
"location": {
|
||||
@@ -826,10 +1046,42 @@
|
||||
},
|
||||
"servicesTabTitle": "Services",
|
||||
"turn": {
|
||||
"title": "Configuration de TURN"
|
||||
"title": "Configuration de TURN",
|
||||
"info": "Utilisez le serveur TURN intégré. Si cette option est désactivée, les paramètres TURN de l'application restent inchangés."
|
||||
},
|
||||
"redis": {
|
||||
"title": "Configuration de Redis"
|
||||
"title": "Configuration de Redis",
|
||||
"info": "Utilisez le service Redis intégré. Si cette option est désactivée, les paramètres Redis de l'application restent inchangés."
|
||||
},
|
||||
"infoTabTitle": "Informations",
|
||||
"info": {
|
||||
"notes": {
|
||||
"title": "Notes de l'administrateur"
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"title": "Archives",
|
||||
"action": "Archives",
|
||||
"noBackup": "Cette application ne dispose pas de sauvegarde. L'archivage nécessite une sauvegarde récente."
|
||||
},
|
||||
"archiveDialog": {
|
||||
"title": "Application d'archivage"
|
||||
},
|
||||
"updateAvailableTooltip": "Mise à jour disponible",
|
||||
"configureTooltip": "Configurer",
|
||||
"forumAction": "Forum",
|
||||
"appLink": {
|
||||
"title": "Lien externe"
|
||||
},
|
||||
"start": {
|
||||
"title": "Démarrer",
|
||||
"description": "Lancez l'application pour qu'elle soit à nouveau disponible.",
|
||||
"action": "Démarrer"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Arrêter",
|
||||
"title": "Arrêter",
|
||||
"description": "Fermez l'application pour économiser les ressources. Sauvegardez vos données avant de fermer l'application afin de conserver les modifications récentes."
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -841,7 +1093,8 @@
|
||||
"name": "Nom",
|
||||
"description": "Les volumes sont des systèmes de fichiers locaux ou distants. Ils peuvent être utilisés comme stockage de données principal d'une application ou comme emplacement de stockage partagé entre les applications.",
|
||||
"removeVolumeDialog": {
|
||||
"removeAction": "Supprimer"
|
||||
"removeAction": "Supprimer",
|
||||
"title": "Supprimer le volume"
|
||||
},
|
||||
"addVolumeDialog": {
|
||||
"title": "Ajouter un volume",
|
||||
@@ -868,7 +1121,9 @@
|
||||
"description": "Le texte ci-dessous s'affichera dans tous les emails sortants de ce domaine.",
|
||||
"plainTextFormat": "Format texte",
|
||||
"htmlFormat": "Format HTML (optionnel)",
|
||||
"title": "Signature"
|
||||
"title": "Signature",
|
||||
"customSignatureSet": "Signature personnalisée configurée",
|
||||
"noSignatureSet": "Aucune signature configurée"
|
||||
},
|
||||
"incoming": {
|
||||
"catchall": {
|
||||
@@ -881,7 +1136,9 @@
|
||||
"title": "Listes de diffusion",
|
||||
"name": "Nom",
|
||||
"everyoneTooltip": "Utilisation de la liste autorisée aux non-membres",
|
||||
"membersOnlyTooltip": "Utilisation de la liste limitée à ses membres"
|
||||
"membersOnlyTooltip": "Utilisation de la liste limitée à ses membres",
|
||||
"emptyPlaceholder": "Pas de listes de diffusion",
|
||||
"noMatchesPlaceholder": "Aucune liste de diffusion correspondante"
|
||||
},
|
||||
"mailboxes": {
|
||||
"usage": "Utilisation",
|
||||
@@ -889,7 +1146,9 @@
|
||||
"title": "Messageries",
|
||||
"owner": "Propriétaire",
|
||||
"name": "Nom",
|
||||
"addAction": "Ajouter"
|
||||
"addAction": "Ajouter",
|
||||
"emptyPlaceholder": "Pas de boîtes aux lettres",
|
||||
"noMatchesPlaceholder": "Aucune boîte aux lettres correspondante"
|
||||
},
|
||||
"sieveServerInfo": "ManageSieve",
|
||||
"incomingServerInfo": "Réception (IMAP)",
|
||||
@@ -900,7 +1159,8 @@
|
||||
"howToConnectDescription": "Utilisez les paramètres ci-dessous pour configurer les clients de messagerie.",
|
||||
"incomingUserInfo": "Identifiant",
|
||||
"incomingPasswordInfo": "Mot de passe",
|
||||
"incomingPasswordUsage": "Mot de passe du propriétaire de la boîte mail"
|
||||
"incomingPasswordUsage": "Mot de passe du propriétaire de la boîte mail",
|
||||
"description": "Recevoir les e-mails entrants pour ce domaine"
|
||||
},
|
||||
"addMailinglistDialog": {
|
||||
"members": "Liste des membres",
|
||||
@@ -916,7 +1176,8 @@
|
||||
},
|
||||
"addMailboxDialog": {
|
||||
"title": "Ajouter une adresse de messagerie",
|
||||
"name": "Nom"
|
||||
"name": "Nom",
|
||||
"incomingDisabledWarning": "La réception des e-mails pour ce domaine n'est pas activée"
|
||||
},
|
||||
"editMailboxDialog": {
|
||||
"title": "Paramétrer l'adresse de messagerie {{ name }}@{{ domain }}",
|
||||
@@ -942,7 +1203,9 @@
|
||||
},
|
||||
"smtpStatus": {
|
||||
"notBlacklisted": "L'adresse IP de ce serveur {{ ip }} <b>n'est pas</b> sur liste noire.",
|
||||
"blacklisted": "L'adresse IP de ce serveur {{ ip }} est sur liste noire."
|
||||
"blacklisted": "L'adresse IP de ce serveur {{ ip }} est sur liste noire.",
|
||||
"outboundSmtp": "SMTP sortant",
|
||||
"rblCheck": "Vérification de la liste noire DNS"
|
||||
},
|
||||
"dnsStatus": {
|
||||
"recordNotSet": "non défini",
|
||||
@@ -977,7 +1240,13 @@
|
||||
},
|
||||
"config": {
|
||||
"title": "Configuration de la messagerie {{ domain }}",
|
||||
"clientConfiguration": "Configuration des clients de messagerie"
|
||||
"clientConfiguration": "Configuration des clients de messagerie",
|
||||
"sending": {
|
||||
"title": "Envoi"
|
||||
},
|
||||
"receiving": {
|
||||
"title": "Réception"
|
||||
}
|
||||
},
|
||||
"editMailinglistDialog": {
|
||||
"title": "Modifier la liste de diffusion {{ name }}@{{ domain }}"
|
||||
@@ -989,7 +1258,11 @@
|
||||
"enablePop3": "Activer l'accès POP3",
|
||||
"activeCheckbox": "L'adresse de messagerie est active"
|
||||
},
|
||||
"howToConnectInfoModal": "Configuration des clients de messagerie"
|
||||
"howToConnectInfoModal": "Configuration des clients de messagerie",
|
||||
"customFrom": {
|
||||
"title": "Autoriser les adresses d'expéditeur personnalisées",
|
||||
"description": "Autoriser les utilisateurs et les applications authentifiés à utiliser n'importe quelle adresse d'expéditeur"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"syncDns": {
|
||||
@@ -1045,12 +1318,24 @@
|
||||
"bunnyAccessKey": "Bunny Access Key",
|
||||
"ovhConsumerKey": "Consumer Key",
|
||||
"ovhAppKey": "Application Key",
|
||||
"ovhAppSecret": "Application Secret"
|
||||
"ovhAppSecret": "Application Secret",
|
||||
"deSecToken": "jeton deSEC",
|
||||
"gandiTokenType": "Type de jeton",
|
||||
"gandiTokenTypeApiKey": "Clé API (obsolète)",
|
||||
"gandiTokenTypePAT": "Jeton d'accès personnel (PAT)",
|
||||
"inwxUsername": "Nom d'utilisateur INWX",
|
||||
"inwxPassword": "Mot de passe INWX",
|
||||
"customNameservers": "Le domaine utilise des serveurs de noms personnalisés (vanity)",
|
||||
"zoneNamePlaceholder": "Facultatif. Si ce paramètre n'est pas fourni, la valeur par défaut est le domaine racine.",
|
||||
"carddavLocation": "Emplacement du serveur CardDAV",
|
||||
"caldavLocation": "Emplacement du serveur CalDAV"
|
||||
},
|
||||
"changeDashboardDomain": {
|
||||
"description": "Cette action entraînera le déplacement du tableau de bord vers le sous-domaine <code>my</code> du domaine sélectionné.",
|
||||
"changeAction": "Changer le domaine",
|
||||
"title": "Changer le domaine du tableau de bord"
|
||||
"title": "Changer le domaine du tableau de bord",
|
||||
"confirmMessage": "Cela invalidera toutes les clés d'accès des utilisateurs.",
|
||||
"confirmTitle": "Voulez-vous vraiment modifier le domaine du tableau de bord ?"
|
||||
},
|
||||
"removeDialog": {
|
||||
"removeAction": "Supprimer",
|
||||
@@ -1063,7 +1348,14 @@
|
||||
},
|
||||
"provider": "Fournisseur",
|
||||
"domain": "Domaine",
|
||||
"title": "Domaines et Certificats"
|
||||
"title": "Domaines et Certificats",
|
||||
"emptyPlaceholder": "Aucun domaine",
|
||||
"noMatchesPlaceholder": "Aucun domaine correspondant",
|
||||
"description": "L'ajout d'un domaine vous permet d'installer des applications sur ses sous-domaines.",
|
||||
"wellknown": {
|
||||
"editAction": "URI courants",
|
||||
"title": "URI courants"
|
||||
}
|
||||
},
|
||||
"branding": {
|
||||
"footer": {
|
||||
@@ -1071,7 +1363,8 @@
|
||||
},
|
||||
"title": "Affichage",
|
||||
"cloudronName": "Nom du Cloudron",
|
||||
"logo": "Logo"
|
||||
"logo": "Logo",
|
||||
"backgroundImage": "Arrière-plan de la page de connexion"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"subject": "Réinitialisation du mot de passe [<%= cloudron %>]",
|
||||
@@ -1120,7 +1413,8 @@
|
||||
"new": "Nouveau",
|
||||
"uploadFolder": "Charger un dossier",
|
||||
"openTerminal": "Ouvrir le terminal",
|
||||
"openLogs": "Afficher les journaux"
|
||||
"openLogs": "Afficher les journaux",
|
||||
"refresh": "Actualiser"
|
||||
},
|
||||
"renameDialog": {
|
||||
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant ?",
|
||||
@@ -1214,7 +1508,9 @@
|
||||
"downloadAction": "Télécharger",
|
||||
"scheduler": "Planificateur/Cron",
|
||||
"download": {
|
||||
"download": "Télécharger"
|
||||
"download": "Télécharger",
|
||||
"title": "Télécharger le fichier",
|
||||
"description": "Indiquez le chemin d'accès d'un fichier ou d'un répertoire à télécharger depuis le système de fichiers de l'application."
|
||||
},
|
||||
"title": "Terminal"
|
||||
},
|
||||
@@ -1240,10 +1536,19 @@
|
||||
"product": "Produit",
|
||||
"memory": "Mémoire",
|
||||
"uptime": "Durée de fonctionnement",
|
||||
"activationTime": "Heure de création de Cloudron"
|
||||
"activationTime": "Heure de création de Cloudron",
|
||||
"cloudronVersion": "Version de Cloudron",
|
||||
"ubuntuVersion": "Version de Ubuntu"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Graphiques"
|
||||
},
|
||||
"locale": {
|
||||
"title": "Paramètres régionaux"
|
||||
},
|
||||
"title": "Système",
|
||||
"settings": {
|
||||
"title": "Paramètres"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
@@ -1279,7 +1584,8 @@
|
||||
"noUsername": {
|
||||
"title": "Impossible de configurer le compte",
|
||||
"description": "Le compte ne peut pas être configuré sans nom d'utilisateur."
|
||||
}
|
||||
},
|
||||
"welcome": "Bienvenue"
|
||||
},
|
||||
"login": {
|
||||
"resetPasswordAction": "Réinitialiser le mot de passe",
|
||||
@@ -1288,7 +1594,11 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
|
||||
"errorIncorrect2FAToken": "Le jeton 2FA n'est pas valide",
|
||||
"errorInternal": "Erreur interne, réessayer ultérieurement"
|
||||
"errorInternal": "Erreur interne, réessayer ultérieurement",
|
||||
"loginAction": "Se connecter",
|
||||
"usePasskeyAction": "Utiliser une clé d'accès",
|
||||
"errorPasskeyFailed": "Échec de la connexion avec la clé d'accès",
|
||||
"passkeyAction": "Se connecter avec la clé d'accès"
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"salutation": "Bonjour <%= user %>,",
|
||||
@@ -1308,7 +1618,8 @@
|
||||
"name": "Nom",
|
||||
"id": "ID du client",
|
||||
"secret": "Secret du client",
|
||||
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)"
|
||||
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)",
|
||||
"loginRedirectUriPlaceholder": "URL séparées par des virgules"
|
||||
},
|
||||
"description": "Cloudron peut agir en tant que fournisseur OpenID Connect pour les applications internes et les services externes.",
|
||||
"deleteClientDialog": {
|
||||
@@ -1324,6 +1635,73 @@
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "URL de découverte"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clients OpenID",
|
||||
"empty": "Aucun client OpenID"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Identifiants du client"
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
"settings": {
|
||||
"title": "Paramètres"
|
||||
}
|
||||
},
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "Aucune application archivée"
|
||||
},
|
||||
"description": "Les applications archivées conservent la dernière sauvegarde effectuée au moment de leur archivage. Ces sauvegardes sont conservées de manière permanente et peuvent être restaurées."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Site",
|
||||
"size": "Taille",
|
||||
"fileCount": "Fichiers"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites de secours",
|
||||
"emptyPlaceholder": "Pas de sites de secours",
|
||||
"lastRun": "Dernier lancement",
|
||||
"description": "Les emplacements de sauvegarde indiquent où sont stockées les sauvegardes du système et des applications. Les sauvegardes des applications peuvent être restaurées individuellement.",
|
||||
"noAutomaticUpdateBackupWarning": "Aucun site de sauvegarde n'est configuré pour stocker les sauvegardes des mises à jour automatiques. Activez l'option « Stocker ici les sauvegardes des mises à jour automatiques » sur au moins un site de sauvegarde pour permettre les mises à jour automatiques."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
"title": "Supprimer le site de secours"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerRegistries": {
|
||||
"server": "Adresse du serveur",
|
||||
"provider": "Fournisseur",
|
||||
"username": "Nom d'utilisateur",
|
||||
"title": "Registres Docker",
|
||||
"description": "Configurer l'accès aux registres Docker privés pour l'installation d'applications personnalisées.",
|
||||
"removeDialog": {
|
||||
"title": "Supprimer le registre Docker"
|
||||
},
|
||||
"email": "E-mail",
|
||||
"passwordToken": "Mot de passe/Jeton",
|
||||
"emptyPlaceholder": "Pas de registres Docker",
|
||||
"dialog": {
|
||||
"addTitle": "Ajouter un registre Docker",
|
||||
"editTitle": "Modifier le registre Docker"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Apparence"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Tableau de bord"
|
||||
},
|
||||
"server": {
|
||||
"title": "Serveur"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Les applications de la communauté ne sont pas vérifiées par Cloudron. N'installez que des applications provenant de développeurs de confiance. Le code tiers peut compromettre la sécurité de votre système.",
|
||||
"unstablewarning": "Cette application est signalée comme instable par son développeur."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
"restart": "Mulai ulang",
|
||||
"reset": "Atur Ulang",
|
||||
"logs": "Log",
|
||||
"loadMore": "Muat lebih banyak"
|
||||
"loadMore": "Muat lebih banyak",
|
||||
"setup": "Siapkan",
|
||||
"disable": "Nonaktifkan"
|
||||
},
|
||||
"searchPlaceholder": "Cari",
|
||||
"actions": "Tindakan",
|
||||
@@ -89,7 +91,7 @@
|
||||
"userManagementAllUsers": "Izinkan semua pengguna di Cloudron ini",
|
||||
"userManagementSelectUsers": "Hanya izinkan pengguna dan grup berikut ini",
|
||||
"errorUserManagementSelectAtLeastOne": "Pilih setidaknya satu pengguna atau grup",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron </a>.",
|
||||
"configuredForCloudronEmail": "Aplikasi ini telah dikonfigurasi sebelumnya untuk digunakan dengan <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mail Cloudron</a>.",
|
||||
"cloudflarePortWarning": "Proksi Cloudflare harus dinonaktifkan agar domain aplikasi dapat mengakses port ini",
|
||||
"portReadOnly": "hanya baca",
|
||||
"ephemeralPortWarning": "Menggunakan port dinamis dapat menyebabkan konflik yang tidak terduga."
|
||||
@@ -326,8 +328,6 @@
|
||||
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan.",
|
||||
"expiresAt": "Tanggal kedaluwarsa"
|
||||
},
|
||||
"disable2FAAction": "Nonaktifkan 2FA",
|
||||
"enable2FAAction": "Aktifkan 2FA",
|
||||
"title": "Profil",
|
||||
"primaryEmail": "E-mail utama",
|
||||
"passwordRecoveryEmail": "E-mail pemulihan kata sandi",
|
||||
@@ -363,9 +363,23 @@
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Autentikasi dua faktor",
|
||||
"disabled": "Dinonaktifkan",
|
||||
"totpEnabled": "Menggunakan kata sandi sekali pakai berbasis waktu (TOTP)",
|
||||
"passkeyEnabled": "Menggunakan passkey"
|
||||
"totpEnabled": "Diaktifkan",
|
||||
"passkeyEnabled": "Diaktifkan",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Belum diatur",
|
||||
"enablePasskey": {
|
||||
"title": "Aktifkan passkey"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "Aktifkan TOTP"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "Nonaktifkan TOTP"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Nonaktifkan Passkey"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -727,17 +741,16 @@
|
||||
"updateAvailableAction": "Pembaruan tersedia",
|
||||
"stopUpdateAction": "Hentikan pembaruan",
|
||||
"disabled": "Dinonaktifkan",
|
||||
"schedule": "Jadwal pembaruan",
|
||||
"description": "Pembaruan platform dan aplikasi diterapkan sesuai jadwal yang telah dikonfigurasi, menggunakan <a href=\"/#/system-settings\">Zona waktu sistem</a>.",
|
||||
"onLatest": "terbaru"
|
||||
"description": "Pembaruan diterapkan sesuai jadwal yang telah dikonfigurasi, menggunakan <a href=\"/#/system-settings\">Zona waktu sistem</a>.",
|
||||
"onLatest": "terbaru",
|
||||
"config": "Pembaruan otomatis",
|
||||
"appsOnly": "Hanya aplikasi",
|
||||
"platformAndApps": "Platform & aplikasi"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Konfigurasi Jadwal Pembaruan Otomatis",
|
||||
"disableCheckbox": "Nonaktifkan pembaruan otomatis",
|
||||
"enableCheckbox": "Aktifkan pembaruan otomatis",
|
||||
"selectOne": "Pilih setidaknya satu hari dan satu waktu",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"description": "Atur hari dan waktu untuk pembaruan otomatis platform dan aplikasi. Pastikan jadwal ini tidak tumpang tindih dengan jadwal pencadangan."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -757,6 +770,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Penyedia registri Docker",
|
||||
"providerOther": "Lainnya"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Konfigurasi Pembaruan Otomatis",
|
||||
"policy": "Kebijakan",
|
||||
"policyDescription": "Pilih apa yang diperbarui secara otomatis",
|
||||
"days": "Hari",
|
||||
"hours": "Jam",
|
||||
"schedule": "Jadwal"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1187,7 +1208,7 @@
|
||||
"aliases": "Alias",
|
||||
"addAliasAction": "Tambahkan alias",
|
||||
"noAliases": "Tidak ada domain alias",
|
||||
"dnsoverwrite": "Beberapa catatan DNS sudah ada. Setuju untuk menimpa."
|
||||
"overwriteDns": "Menimpa catatan DNS yang ada pada {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -1403,7 +1424,7 @@
|
||||
"title": "Pembaruan otomatis"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan di App Store. Anda juga dapat memeriksanya secara manual."
|
||||
"description": "Cloudron secara otomatis memeriksa pembaruan. Anda juga dapat memeriksanya secara manual."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -1636,7 +1657,8 @@
|
||||
"title": "Situs Cadangan",
|
||||
"emptyPlaceholder": "Tidak ada situs cadangan",
|
||||
"lastRun": "Terakhir dijalankan",
|
||||
"description": "Lokasi cadangan menunjukkan di mana cadangan sistem dan cadangan aplikasi disimpan. Cadangan aplikasi dapat dipulihkan secara terpisah."
|
||||
"description": "Lokasi cadangan menunjukkan di mana cadangan sistem dan cadangan aplikasi disimpan. Cadangan aplikasi dapat dipulihkan secara terpisah.",
|
||||
"noAutomaticUpdateBackupWarning": "Tidak ada situs cadangan yang dikonfigurasi untuk menyimpan cadangan pembaruan otomatis. Aktifkan \"Simpan cadangan pembaruan otomatis di sini\" pada setidaknya satu situs cadangan untuk memungkinkan pembaruan otomatis."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1668,7 +1690,9 @@
|
||||
"appDown": "Aplikasi sedang tidak berfungsi",
|
||||
"rebootRequired": "Diperlukan menyalakan ulang server",
|
||||
"cloudronUpdateFailed": "Pembaruan Cloudron gagal",
|
||||
"diskSpace": "Ruang disk hampir penuh"
|
||||
"diskSpace": "Ruang disk hampir penuh",
|
||||
"appAutoUpdateFailed": "Pembaruan otomatis aplikasi gagal",
|
||||
"manualUpdateRequired": "Platform atau aplikasi memerlukan pembaruan manual"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "E-mail akan dikirimkan ke e-mail utama Anda untuk acara-acara yang dipilih."
|
||||
@@ -1694,7 +1718,9 @@
|
||||
"errorIncorrect2FAToken": "Token 2FA tidak valid",
|
||||
"errorInternal": "Terjadi kesalahan internal, coba lagi nanti",
|
||||
"loginAction": "Masuk",
|
||||
"usePasskeyAction": "Gunakan passkey"
|
||||
"usePasskeyAction": "Gunakan passkey",
|
||||
"errorPasskeyFailed": "Gagal masuk dengan passkey",
|
||||
"passkeyAction": "Masuk dengan passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Pengaturan ulang kata sandi",
|
||||
|
||||
@@ -560,8 +560,6 @@
|
||||
"title": "Backup"
|
||||
},
|
||||
"profile": {
|
||||
"enable2FAAction": "Abilita 2FA",
|
||||
"disable2FAAction": "Disabilita 2FA",
|
||||
"changePasswordAction": "Cambia Password",
|
||||
"createApiToken": {
|
||||
"copyNow": "Copia il token API ora. Non verrà mostrato di nuovo per motivi di sicurezza.",
|
||||
@@ -782,12 +780,9 @@
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"description": "Seleziona i giorni e gli orari durante i quali Cloudron applicherà gli aggiornamenti automatici della piattaforma e dell'app. Fai attenzione a non sovrapporre questa pianificazione alla <a href=\"/#/backups\">pianificazione dei backup</a>.",
|
||||
"hours": "Ore",
|
||||
"days": "Giorni",
|
||||
"selectOne": "Seleziona almeno un giorno e un'ora",
|
||||
"enableCheckbox": "Abilita Aggiornamenti Automatici",
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici",
|
||||
"title": "Configura pianificazione aggiornamenti automatici"
|
||||
"disableCheckbox": "Disabilita Aggiornamenti Automatici"
|
||||
},
|
||||
"updates": {
|
||||
"stopUpdateAction": "Ferma Aggiornamento",
|
||||
|
||||
@@ -47,7 +47,9 @@
|
||||
"configure": "Configureer",
|
||||
"restart": "Herstart",
|
||||
"reset": "Reset",
|
||||
"loadMore": "Laad meer"
|
||||
"loadMore": "Laad meer",
|
||||
"setup": "Instellen",
|
||||
"disable": "Uitschakelen"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Herstart Server",
|
||||
@@ -348,8 +350,6 @@
|
||||
"allowedIpRanges": "Toegestane IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Verander Wachtwoord",
|
||||
"disable2FAAction": "Twee-Factor (2FA) authenticatie uitschakelen",
|
||||
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail gestuurd naar {{ email }}"
|
||||
},
|
||||
@@ -363,9 +363,23 @@
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Twee-Factor (2FA) authenticatie",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"totpEnabled": "Gebruikt tijdgebaseerd eenmalige wachtwoord (TOTP)",
|
||||
"passkeyEnabled": "Gebruikt passkey"
|
||||
"totpEnabled": "Ingeschakeld",
|
||||
"passkeyEnabled": "Ingeschakeld",
|
||||
"totpTitle": "TOTP",
|
||||
"passkeyTitle": "Passkey"
|
||||
},
|
||||
"notSet": "Niet ingesteld",
|
||||
"enablePasskey": {
|
||||
"title": "Passkey activeren"
|
||||
},
|
||||
"enableTotp": {
|
||||
"title": "TOTP activeren"
|
||||
},
|
||||
"disableTotp": {
|
||||
"title": "TOTP Uitschakelen"
|
||||
},
|
||||
"disablePasskey": {
|
||||
"title": "Passkey uitschakelen"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -772,7 +786,7 @@
|
||||
"noAliases": "Geen alias-domeinen",
|
||||
"addAliasAction": "Alias toevoegen",
|
||||
"aliases": "Aliassen",
|
||||
"dnsoverwrite": "Sommige DNS records bestaan al. Weet je zeker dat ze overschreven moeten worden?"
|
||||
"overwriteDns": "Overschrijf bestaande DNS records van {domains}"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
@@ -1144,18 +1158,17 @@
|
||||
"checkForUpdatesAction": "Controleer op updates",
|
||||
"updateAvailableAction": "Update beschikbaar",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"description": "Platform en app updates worden toegepast met de geconfigureerde planning met deze <a href=\"/#/system-locale\">Systeem tijdzone</a>.",
|
||||
"description": "Updates worden toegepast volgens het geconfigureerde schema, met behulp van de <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"disabled": "Uitgeschakeld",
|
||||
"schedule": "Update planning",
|
||||
"onLatest": "Laatste"
|
||||
"onLatest": "Laatste",
|
||||
"config": "Automatische updates",
|
||||
"appsOnly": "Alleen Apps",
|
||||
"platformAndApps": "Platform & Apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"disableCheckbox": "Automatische updates uitschakelen",
|
||||
"enableCheckbox": "Automatische updates inschakelen",
|
||||
"selectOne": "Selecteer minstens één dag en tijd",
|
||||
"days": "Dagen",
|
||||
"hours": "Uren",
|
||||
"title": "Automatische Update Planning configureren",
|
||||
"description": "Stel de dagen en uren in voor automatische updates van het platform en apps. Zorg ervoor dat dit schema niet overlapt met de back-upschema's."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1176,6 +1189,14 @@
|
||||
"registryConfig": {
|
||||
"provider": "Docker registry aanbieder",
|
||||
"providerOther": "Anders"
|
||||
},
|
||||
"configureUpdates": {
|
||||
"title": "Automatische updates configureren",
|
||||
"policy": "Beleid",
|
||||
"policyDescription": "Kies wat er automatisch wordt bijgewerkt",
|
||||
"days": "Dagen",
|
||||
"hours": "Uren",
|
||||
"schedule": "Planning"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
@@ -1233,7 +1254,9 @@
|
||||
"appDown": "App werkt niet",
|
||||
"rebootRequired": "Server herstart noodzakelijk",
|
||||
"cloudronUpdateFailed": "Cloudron update mislukt",
|
||||
"diskSpace": "Weinig diskruimte"
|
||||
"diskSpace": "Weinig diskruimte",
|
||||
"appAutoUpdateFailed": "Automatische update van de app is mislukt",
|
||||
"manualUpdateRequired": "Platform of app moet handmatig geüpdatet worden"
|
||||
},
|
||||
"settingsDialog": {
|
||||
"description": "Een e-mail wordt verstuurd voor de geselecteerde gebeurtenissen naar je primaire e-mail."
|
||||
@@ -1520,7 +1543,9 @@
|
||||
"errorIncorrect2FAToken": "2FA token is niet geldig",
|
||||
"errorInternal": "Interne fout, probeer later opnieuw",
|
||||
"loginAction": "Inloggen",
|
||||
"usePasskeyAction": "Gebruik een passkey"
|
||||
"usePasskeyAction": "Gebruik een passkey",
|
||||
"errorPasskeyFailed": "Inloggen met passkey mislukt",
|
||||
"passkeyAction": "Inloggen met een passkey"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Wachtwoord herstellen",
|
||||
|
||||
@@ -180,8 +180,6 @@
|
||||
"description": "As palavras-passe da aplicação são uma medida de segurança para proteger a sua conta de utilizador Cloudron. Se precisar de aceder a uma aplicação Cloudron a partir de uma aplicação móvel ou cliente não fidedigno, pode iniciar a sessão com o seu nome de utilizador e a palavra-passe alternativa gerada aqui."
|
||||
},
|
||||
"changePasswordAction": "Alterar palavra-passe",
|
||||
"disable2FAAction": "Desativar 2FA",
|
||||
"enable2FAAction": "Ativar 2FA",
|
||||
"removeAppPassword": {
|
||||
"title": "Remover Palavra-passe da Aplicação",
|
||||
"description": "Remover a palavra-passe da aplicação \"{{ name }}\"?"
|
||||
@@ -619,7 +617,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Procurar por Atualizações",
|
||||
"schedule": "Agendar",
|
||||
"updateAvailableAction": "Disponível Atualização",
|
||||
"stopUpdateAction": "Parar Atualização",
|
||||
"disabled": "Desativada"
|
||||
@@ -633,8 +630,6 @@
|
||||
"blockingAppsInfo": "Por favor, aguarde que as operações em cima terminem."
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"disableCheckbox": "Desativar Atualizações Automáticas",
|
||||
"enableCheckbox": "Ativar Atualizações Automáticas",
|
||||
"selectOne": "Selecione pelo menos um dia e hora"
|
||||
|
||||
@@ -283,18 +283,23 @@
|
||||
"disable": "Отключить"
|
||||
},
|
||||
"enable2FA": {
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"authenticatorAppDescription": "Используйте Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>,<a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) или аналогичные TOTP приложения для сканирования секретного кода.",
|
||||
"title": "Включить двухфакторную аутентификацию (2FA)",
|
||||
"token": "Токен",
|
||||
"enable": "Включить",
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить."
|
||||
"mandatorySetup": "Для доступа к панели управления требуется 2FA. Пожалуйста, закончите настройку, чтобы продолжить.",
|
||||
"passkeyOption": "Ключ доступа",
|
||||
"totpOption": "TOTP",
|
||||
"registerPasskey": "Настроить ключ доступа",
|
||||
"passkeyDescription": "Браузер предложит вам создать ключ доступа с помощью биометрических данных вашего устройства или менеджера паролей."
|
||||
},
|
||||
"appPasswords": {
|
||||
"description": "Пароли приложений - это мера безопасности, направленная на защиту вашего аккаунта Cloudron от несанкционированного доступа. Если вам необходим доступ к Cloudron с ненадёжного мобильного или десктопного приложения, вы можете войти под своим именем пользователя и использовать с ним специально сгенерированный пароль.",
|
||||
"title": "Пароли приложений",
|
||||
"app": "Приложение",
|
||||
"name": "Имя",
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют"
|
||||
"noPasswordsPlaceholder": "Пароли приложений отсутствуют",
|
||||
"expires": "Истекает"
|
||||
},
|
||||
"title": "Профиль",
|
||||
"primaryEmail": "Основной Email",
|
||||
@@ -331,7 +336,8 @@
|
||||
"name": "Имя пароля",
|
||||
"app": "Приложение",
|
||||
"description": "Используйте этот пароль для аутентификации в приложении:",
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности."
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности.",
|
||||
"expiresAt": "Истекает в"
|
||||
},
|
||||
"createApiToken": {
|
||||
"copyNow": "Пожалуйста, скопируйте сгенерированный API Токен. Он не будет показан снова из соображений безопасности.",
|
||||
@@ -342,8 +348,6 @@
|
||||
"allowedIpRanges": "Разрешённые диапазоны IP"
|
||||
},
|
||||
"changePasswordAction": "Изменить пароль",
|
||||
"disable2FAAction": "Выключить 2FA",
|
||||
"enable2FAAction": "Включить 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Письмо отправлено на адрес электронной почты {{ email }}"
|
||||
},
|
||||
@@ -354,6 +358,11 @@
|
||||
"removeAppPassword": {
|
||||
"title": "Удалить пароль приложения",
|
||||
"description": "Удалить пароль приложения \"{{ name }}\" ?"
|
||||
},
|
||||
"twoFactorAuth": {
|
||||
"title": "Двухфакторная аутентификация",
|
||||
"totpEnabled": "Используется одноразовый пароль (TOTP)",
|
||||
"passkeyEnabled": "Используется ключ доступа"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@@ -367,7 +376,7 @@
|
||||
"customAppUpdateInfo": "Для сторонних приложений автообновления недоступны.",
|
||||
"description": "Название & версия приложения",
|
||||
"appId": "ID приложения",
|
||||
"packageVersion": "Пакет",
|
||||
"packageVersion": "Версия пакета",
|
||||
"lastUpdated": "Обновлен",
|
||||
"installedAt": "Установлено",
|
||||
"packager": "Сборщик"
|
||||
@@ -377,7 +386,7 @@
|
||||
"description": "Обновления приложения применяются периодически, в соответствии с <a href=\"/#/system-update\">расписанием обновлений</a>"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron автоматически проверяет Магазин приложений на наличие обновлений. Вы также можете проверить их вручную."
|
||||
"description": "Cloudron автоматически проверяет наличие обновлений для приложений. Вы также можете проверить их вручную."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -410,8 +419,7 @@
|
||||
"saveAction": "Сохранить",
|
||||
"aliases": "Псевдонимы",
|
||||
"addAliasAction": "Добавить псевдоним",
|
||||
"noAliases": "Домены-псевдонимы отсутствуют",
|
||||
"dnsoverwrite": "Некоторые DNS записи уже существуют. Подтвердите перезапись."
|
||||
"noAliases": "Домены-псевдонимы отсутствуют"
|
||||
},
|
||||
"accessControl": {
|
||||
"sftp": {
|
||||
@@ -644,7 +652,7 @@
|
||||
"cloneDialog": {
|
||||
"title": "Клонировать приложение",
|
||||
"location": "Расположение",
|
||||
"description": "Клон использует резервную копию версии <b>v{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
"description": "Клон использует резервную копию версии <b>{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Добавить Внешнюю ссылку"
|
||||
@@ -687,6 +695,16 @@
|
||||
"forumAction": "Форум",
|
||||
"appLink": {
|
||||
"title": "Внешняя ссылка"
|
||||
},
|
||||
"start": {
|
||||
"title": "Старт",
|
||||
"description": "Запустить приложение и сделать его снова доступным.",
|
||||
"action": "Старт"
|
||||
},
|
||||
"stop": {
|
||||
"action": "Стоп",
|
||||
"title": "Стоп",
|
||||
"description": "Остановить приложение, чтобы сохранить ресурсы. Создайте резервную копию перед этим, чтобы сохранить последние изменения."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -796,7 +814,8 @@
|
||||
"size": "Размер",
|
||||
"duration": "Продолжительность резервного копирования",
|
||||
"lastIntegrityCheck": "Последняя проверка целостности",
|
||||
"integrityNever": "никогда"
|
||||
"integrityNever": "никогда",
|
||||
"integrityInProgress": "В процессе"
|
||||
},
|
||||
"backupEdit": {
|
||||
"title": "Редактировать резервную копию",
|
||||
@@ -839,7 +858,8 @@
|
||||
},
|
||||
"useFileAndFileNameEncryption": "Используется шифрование файлов и их имён",
|
||||
"useFileEncryption": "Используется шифрование файлов",
|
||||
"checkIntegrity": "Проверить целостность"
|
||||
"checkIntegrity": "Проверить целостность",
|
||||
"stopIntegrity": "Остановить проверку целостности"
|
||||
},
|
||||
"branding": {
|
||||
"title": "Брендирование",
|
||||
@@ -1026,17 +1046,13 @@
|
||||
"updateAvailableAction": "Доступно обновление",
|
||||
"stopUpdateAction": "Остановить обновление",
|
||||
"description": "Обновления платформы и приложений запускаются с учётом установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
|
||||
"schedule": "Расписание обновлений",
|
||||
"disabled": "Выключено",
|
||||
"onLatest": "последний"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Настроить расписание автоматических обновлений",
|
||||
"disableCheckbox": "Выключить автоматические обновления",
|
||||
"enableCheckbox": "Включить автоматические обновления",
|
||||
"selectOne": "Выберите по крайней мере один день и время",
|
||||
"days": "Дни",
|
||||
"hours": "Часы",
|
||||
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -1113,7 +1129,9 @@
|
||||
"changeDashboardDomain": {
|
||||
"title": "Домен панели управления",
|
||||
"changeAction": "Изменить домен",
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена"
|
||||
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена",
|
||||
"confirmMessage": "Это действие сбросит ключи доступа для всех пользователей.",
|
||||
"confirmTitle": "Вы точно хотите сменить домен панели управления?"
|
||||
},
|
||||
"domainDialog": {
|
||||
"editTitle": "Редактировать домен",
|
||||
@@ -1171,7 +1189,9 @@
|
||||
"inwxUsername": "Имя пользователя INWX",
|
||||
"inwxPassword": "Пароль INWX",
|
||||
"customNameservers": "Домен использует пользовательские серверы имён (vanity)",
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен."
|
||||
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен.",
|
||||
"carddavLocation": "Расположение сервера CardDAV",
|
||||
"caldavLocation": "Расположение сервера CalDAV"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Удалить домен",
|
||||
@@ -1258,7 +1278,8 @@
|
||||
"restartApp": "Перезагрузить приложение",
|
||||
"uploadFolder": "Загрузить папку",
|
||||
"openTerminal": "Открыть терминал",
|
||||
"openLogs": "Открыть логи"
|
||||
"openLogs": "Открыть логи",
|
||||
"refresh": "Обновить"
|
||||
},
|
||||
"removeDialog": {
|
||||
"reallyDelete": "Действительно удалить?"
|
||||
@@ -1490,7 +1511,8 @@
|
||||
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
|
||||
"errorIncorrect2FAToken": "Неверный 2FA токен",
|
||||
"errorInternal": "Внутренняя ошибка, попробуйте позже",
|
||||
"loginAction": "Войти"
|
||||
"loginAction": "Войти",
|
||||
"usePasskeyAction": "Использовать ключ доступа"
|
||||
},
|
||||
"passwordReset": {
|
||||
"title": "Сброс пароля",
|
||||
@@ -1647,7 +1669,8 @@
|
||||
"title": "Локации резервных копий",
|
||||
"emptyPlaceholder": "Локации отсутствуют",
|
||||
"lastRun": "Последний запуск",
|
||||
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности."
|
||||
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности.",
|
||||
"noAutomaticUpdateBackupWarning": "Не настроено ни одной локации резервных копий для хранения копий автоматических обновлений. Включите \"Хранить бэкапы автоматических обновлений здесь\" по крайней мере в одной локации, чтобы активировать автоматические обновления."
|
||||
},
|
||||
"site": {
|
||||
"removeDialog": {
|
||||
@@ -1688,6 +1711,7 @@
|
||||
"title": "Сервер"
|
||||
},
|
||||
"communityapp": {
|
||||
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы."
|
||||
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы.",
|
||||
"unstablewarning": "Разработчик пометил это приложение как нестабильное."
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,6 @@
|
||||
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。"
|
||||
},
|
||||
"changePasswordAction": "修改密码",
|
||||
"disable2FAAction": "停用双因素验证",
|
||||
"enable2FAAction": "启用双因素验证",
|
||||
"title": "个人资料",
|
||||
"primaryEmail": "主要 Email",
|
||||
"passwordRecoveryEmail": "密码恢复 Email",
|
||||
@@ -534,12 +532,9 @@
|
||||
"stopUpdateAction": "停止更新"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "配置自动更新时间表",
|
||||
"disableCheckbox": "停用自动更新",
|
||||
"enableCheckbox": "启用自动更新",
|
||||
"selectOne": "选择至少一个日期和时间",
|
||||
"days": "星期",
|
||||
"hours": "小时",
|
||||
"description": "选择检查平台和应用更新的日子和时间。请注意这个时间不要和 <a href=\"/#/backups\">备份时间</a> 冲突。"
|
||||
},
|
||||
"updateDialog": {
|
||||
|
||||
@@ -8,12 +8,13 @@ import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, InputDialog, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from './utils.js';
|
||||
import ProfileModel from './models/ProfileModel.js';
|
||||
import ProvisionModel from './models/ProvisionModel.js';
|
||||
import NotificationsModel from './models/NotificationsModel.js';
|
||||
import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import AppstoreModel from './models/AppstoreModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
@@ -277,6 +278,7 @@ const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
@@ -389,6 +391,9 @@ async function refreshConfigAndFeatures() {
|
||||
console.log('Dashboard version changed, reloading');
|
||||
localStorage.setItem('version', result.version);
|
||||
window.location.reload(true);
|
||||
|
||||
// return never ending promise to just wait for the reload
|
||||
return new Promise(() => {});
|
||||
}
|
||||
|
||||
config.value = result;
|
||||
@@ -402,6 +407,14 @@ async function refreshNotifications() {
|
||||
notificationCount.value = result.length;
|
||||
}
|
||||
|
||||
async function refreshSubscription() {
|
||||
const [error, result] = await appstoreModel.getSubscription();
|
||||
if (error && error.status === 402) console.error('Not yet registered');
|
||||
else if (error && error.status === 412) window.location.href = ''
|
||||
else if (error) console.error(error);
|
||||
else subscription.value = result;
|
||||
}
|
||||
|
||||
async function onOnline() {
|
||||
ready.value = true;
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
@@ -413,6 +426,7 @@ function checkForMobile() {
|
||||
}
|
||||
|
||||
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
|
||||
provide('subscription', subscription);
|
||||
provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
@@ -433,19 +447,18 @@ onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
localStorage.setItem('redirectToHash', window.location.hash);
|
||||
|
||||
// start oidc flow
|
||||
window.location.href = `${API_ORIGIN}/openid/auth?client_id=` + (API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
const clientId = API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN;
|
||||
window.location.href = await startAuthFlow(clientId, API_ORIGIN);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
await refreshProfile();
|
||||
|
||||
// ensure language from profile if set
|
||||
if (profile.value.language) await setLanguage(profile.value.language, true);
|
||||
|
||||
await refreshConfigAndFeatures();
|
||||
|
||||
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
@@ -453,16 +466,16 @@ onMounted(async () => {
|
||||
|
||||
console.log(`Cloudron dashboard v${config.value.version}`);
|
||||
|
||||
if (profile.value.isAtLeastAdmin) refreshNotifications();
|
||||
if (profile.value.isAtLeastAdmin) {
|
||||
refreshNotifications();
|
||||
refreshSubscription();
|
||||
}
|
||||
|
||||
ready.value = true;
|
||||
|
||||
// when done, redirect the user to setup 2fa if it is mandatory and neither totp (twoFactorAuthenticationEnabled) nor passkey is setup
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) {
|
||||
const [error, result] = await profileModel.getPasskey();
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
if (!result) return window.location.href = VIEWS.PROFILE;
|
||||
// when done, redirect the user to setup 2fa if it is mandatory and neither totp nor passkey is setup
|
||||
if (config.value.mandatory2FA && !profile.value.totpEnabled && !profile.value.hasPasskey) {
|
||||
return window.location.href = VIEWS.PROFILE;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -484,7 +497,7 @@ onUnmounted(() => {
|
||||
<SideBar v-if="profile.isAtLeastUserManager" :items="menuItems" :cloudron-name="config.cloudronName" :cloudron-avatar-url="avatarUrl"/>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<Headerbar :config="config" :subscription="subscription" :notification-count="notificationCount"/>
|
||||
<Headerbar :config="config" :notification-count="notificationCount"/>
|
||||
|
||||
<div style="display: flex; justify-content: center; overflow: auto; flex-grow: 1; padding: 0; margin: 0 10px; position: relative;">
|
||||
<KeepAlive>
|
||||
|
||||
@@ -16,6 +16,7 @@ import TokensModel from '../models/TokensModel.js';
|
||||
const tokensModel = TokensModel.create();
|
||||
|
||||
const apiTokens = ref([]);
|
||||
const loading = ref(true);
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const newDialog = useTemplateRef('newDialog');
|
||||
const addedToken = ref('');
|
||||
@@ -122,6 +123,7 @@ async function onRevokeToken(apiToken) {
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshApiTokens();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -184,7 +186,7 @@ onMounted(async () => {
|
||||
<div v-html="$t('profile.apiTokens.description', { apiDocsLink: 'https://docs.cloudron.io/api.html' })"></div>
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<TableView :columns="columns" :model="apiTokens" :busy="loading" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<template #lastUsedTime="{ item:apiToken }">
|
||||
<span v-if="apiToken.lastUsedTime">{{ prettyLongDate(apiToken.lastUsedTime) }}</span>
|
||||
<span v-else>{{ $t('profile.apiTokens.neverUsed') }}</span>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted, inject, watch } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { Button, Checkbox, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
@@ -42,6 +42,12 @@ const domains = ref([]);
|
||||
|
||||
const form = ref(null); // assigned via "Function Ref" because it is inside v-if
|
||||
const isFormValid = ref(false);
|
||||
function resetDnsOverwrite() {
|
||||
needsOverwriteDns.value = [];
|
||||
overwriteDns.value = false;
|
||||
formError.value = {};
|
||||
}
|
||||
|
||||
async function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
@@ -89,7 +95,8 @@ const tcpPorts = ref({});
|
||||
const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const needsOverwriteDns = ref(false);
|
||||
const overwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref([]);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
@@ -98,7 +105,7 @@ function onDomainChange() {
|
||||
domainProvider.value = tmp ? tmp.provider : '';
|
||||
}
|
||||
|
||||
async function onSubmit(overwriteDns) {
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
@@ -111,6 +118,7 @@ async function onSubmit(overwriteDns) {
|
||||
|
||||
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].value });
|
||||
|
||||
const conflicting = [];
|
||||
for (const d of checkForDomains) {
|
||||
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
|
||||
if (error) {
|
||||
@@ -119,12 +127,14 @@ async function onSubmit(overwriteDns) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
if (result.needsOverwrite && !overwriteDns) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = true;
|
||||
formError.value.dnsExists = `DNS record for ${d.subdomain}.${d.domain} already exists`;
|
||||
return;
|
||||
}
|
||||
if (result.needsOverwrite) conflicting.push((d.subdomain ? d.subdomain + '.' : '') + d.domain);
|
||||
}
|
||||
|
||||
if (conflicting.length > 0 && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = conflicting;
|
||||
formError.value.generic = `DNS records of ${conflicting.join(', ')} already exist`;
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -133,7 +143,7 @@ async function onSubmit(overwriteDns) {
|
||||
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
|
||||
};
|
||||
|
||||
if (overwriteDns) config.overwriteDns = true;
|
||||
if (overwriteDns.value) config.overwriteDns = true;
|
||||
|
||||
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
|
||||
|
||||
@@ -185,7 +195,7 @@ function onClose() {
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
result.forEach(u => { u.label = u.displayName || u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
@@ -225,7 +235,8 @@ defineExpose({
|
||||
accessRestrictionAcl.value = { users: [], groups: [] };
|
||||
domainProvider.value = '';
|
||||
upstreamUri.value = '';
|
||||
needsOverwriteDns.value = '';
|
||||
overwriteDns.value = false;
|
||||
needsOverwriteDns.value = [];
|
||||
|
||||
domainList.forEach(d => {
|
||||
d.label = '.' + d.domain;
|
||||
@@ -296,18 +307,15 @@ defineExpose({
|
||||
<div class="description" v-html="description"></div>
|
||||
</div>
|
||||
<div v-else-if="step === STEP.INSTALL">
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<div class="error-label" v-if="formError.dnsExists">{{ formError.dnsExists }}</div>
|
||||
|
||||
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit(false)" autocomplete="off" @input="checkValidity()">
|
||||
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="busy" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': formError.location }">
|
||||
<label for="location">{{ $t('appstore.installDialog.location') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10" required/>
|
||||
<TextInput id="location" ref="locationInput" v-model="location" @input="resetDnsOverwrite()" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange(); resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
|
||||
<div class="error-label" v-if="formError.location">{{ formError.location }}</div>
|
||||
@@ -317,8 +325,8 @@ defineExpose({
|
||||
<label :for="'secondaryDomainInput' + key">{{ port.title }}</label>
|
||||
<small>{{ port.description }}</small>
|
||||
<InputGroup>
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" required/>
|
||||
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" @input="resetDnsOverwrite()" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" @select="resetDnsOverwrite()" required/>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
@@ -330,9 +338,13 @@ defineExpose({
|
||||
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<Checkbox v-if="needsOverwriteDns.length" v-model="overwriteDns" style="margin-top: 10px" :label="$t('app.location.overwriteDns', { domains: needsOverwriteDns.join(', ') })"/>
|
||||
|
||||
<div class="bottom-button-bar">
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
<Button @click="onSubmit()" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid || (needsOverwriteDns.length > 0 && !overwriteDns)" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -65,6 +65,7 @@ const identifier = ref('');
|
||||
const expiresAtDate = ref('');
|
||||
const minExpiresAt = new Date().toISOString().slice(0, 16);
|
||||
const addError = ref('');
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
|
||||
const appsById = {};
|
||||
@@ -163,7 +164,7 @@ onMounted(async () => {
|
||||
if (app.manifest.addons.email) return;
|
||||
|
||||
const ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
|
||||
const sso = app.sso && (app.manifest.addons.ldap || app.manifest.addons.proxyAuth);
|
||||
const sso = app.sso && (app.manifest.addons.ldap || app.manifest.addons.oidc || app.manifest.addons.proxyAuth);
|
||||
|
||||
if (!ftp && !sso) return;
|
||||
|
||||
@@ -179,6 +180,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
await refresh();
|
||||
loading.value = false;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -242,7 +244,7 @@ onMounted(async () => {
|
||||
<div>{{ $t('profile.appPasswords.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<TableView :columns="columns" :model="passwords" :busy="loading" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #name="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.name }}</span></template>
|
||||
<template #label="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.label }}</span></template>
|
||||
<template #creationTime="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ prettyLongDate(password.creationTime) }}</span></template>
|
||||
|
||||
@@ -179,7 +179,7 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
|
||||
<label>{{ $t('app.display.icon') }}</label>
|
||||
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -155,18 +155,20 @@ defineExpose({
|
||||
<div class="info-value">
|
||||
<a v-if="backup.integrityCheckTask?.active" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">{{ $t('backups.backupDetails.integrityInProgress') }}</a>
|
||||
<a v-else-if="backup.lastIntegrityCheckTime && backup.integrityCheckTask" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">
|
||||
<i v-if="backup.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle"></i>
|
||||
<i v-else class="fa-solid fa-times-circle"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</a>
|
||||
<span v-else-if="backup.lastIntegrityCheckTime">
|
||||
<i v-if="backup.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle"></i>
|
||||
<i v-else class="fa-solid fa-times-circle"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('backups.backupDetails.integrityNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(backup.integrityCheckStatus === 'failed' || backup.integrityCheckStatus === 'skipped') && backup.integrityCheckResult?.messages?.length">
|
||||
<div class="info-label" style="margin-bottom: 5px;">Integrity Issues</div>
|
||||
<textarea readonly rows="10" style="width: 100%; resize: vertical;" :value="backup.integrityCheckResult.messages.join('\n')"></textarea>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
|
||||
|
||||
@@ -188,12 +190,10 @@ defineExpose({
|
||||
</template>
|
||||
<template #integrity="{ item:content }">
|
||||
<a v-if="content.lastIntegrityCheckTime && content.integrityCheckTask" :href="`/logs.html?taskId=${content.integrityCheckTask.id}`" target="_blank" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i v-if="content.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
<i v-else class="fa-solid fa-times-circle" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</a>
|
||||
<div v-else-if="content.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i v-if="content.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
<i v-else class="fa-solid fa-times-circle" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
|
||||
@@ -135,7 +135,7 @@ onMounted(async () => {
|
||||
<FormGroup v-if="provider === 'mountpoint'">
|
||||
<label for="mountPointInput">{{ $t('backups.configureBackupStorage.mountPoint') }}</label>
|
||||
<TextInput id="mountPointInput" v-model="providerConfig.mountPoint" placeholder="/mnt/backups" required/>
|
||||
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
|
||||
<small class="warning-label" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: 'https://docs.cloudron.io/backups/#user-managed-mount-point' })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
@@ -172,12 +172,6 @@ onMounted(async () => {
|
||||
<SingleSelect id="blockDevicePath" v-if="provider === 'xfs'" v-model="providerConfig.mountOptionDiskPath" :options="xfsBlockDevices" option-label="label" option-key="path"/>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Disk -->
|
||||
<FormGroup v-if="provider === 'disk'">
|
||||
<label class="control-label">{{ $t('backups.configureBackupStorage.diskPath') }}</label>
|
||||
<TextInput id="mountOptionDiskPathInput" v-model="providerConfig.mountOptionDiskPath" placeholder="/dev/disk/by-uuid/uuid" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- SSHFS -->
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionPortInput">{{ $t('backups.configureBackupStorage.port') }}</label>
|
||||
|
||||
@@ -124,7 +124,7 @@ async function onSubmit() {
|
||||
data.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
data.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs' || provider.value === 'disk') {
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
data.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
data.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, prettySiteLocation } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -205,7 +205,7 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.provider && site.config">
|
||||
<label><i v-if="site.encrypted" class="fa-solid fa-lock"></i> Storage: <b>{{ site.provider }} ({{ site.format }}) </b></label>
|
||||
<div>{{ prettySiteLocation(site) }}</div>
|
||||
<div>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
|
||||
@@ -10,6 +10,7 @@ const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const urlInput = useTemplateRef('urlInput');
|
||||
|
||||
const formError = ref({});
|
||||
const versionsUrl = ref('');
|
||||
@@ -56,6 +57,7 @@ defineExpose({
|
||||
unstable.value = false;
|
||||
dialog.value.open();
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
setTimeout(() => urlInput.value.focus(), 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -81,7 +83,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="urlInput">CloudronVersions.json URL</label>
|
||||
<TextInput id="urlInput" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
<TextInput id="urlInput" ref="urlInput" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -71,7 +71,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('profile.disable2FA.title')"
|
||||
:title="twoFAMethod === 'totp' ? $t('profile.disableTotp.title') : $t('profile.disablePasskey.title')"
|
||||
:confirm-label="$t('profile.disable2FA.disable')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
|
||||
@@ -319,7 +319,7 @@ function onGcdnsFileInputChange(event) {
|
||||
<Checkbox v-if="showAdvanced" v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
|
||||
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label>Certificate provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label>Certificate provider <sup><a href="https://docs.cloudron.io/domains#certificates" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Button, ButtonGroup, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { Button, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
|
||||
import { startRegistration } from '@simplewebauthn/browser';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
@@ -64,37 +64,42 @@ async function onRegisterPasskey() {
|
||||
passkeyRegisterBusy.value = false;
|
||||
}
|
||||
|
||||
async function loadTotpSecret() {
|
||||
const [error, result] = await profileModel.setTotpSecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
totpSecret.value = result.secret;
|
||||
totpQRCode.value = result.qrcode;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
setupMode.value = 'passkey';
|
||||
async open(method) {
|
||||
setupMode.value = method || 'passkey';
|
||||
totpEnableError.value = '';
|
||||
totpToken.value = '';
|
||||
passkeyRegisterError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
const [error, result] = await profileModel.setTotpSecret();
|
||||
if (error) return console.error(error);
|
||||
|
||||
totpSecret.value = result.secret;
|
||||
totpQRCode.value = result.qrcode;
|
||||
if (setupMode.value === 'totp') await loadTotpSecret();
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function switchMode(mode) {
|
||||
setupMode.value = mode;
|
||||
if (mode === 'totp' && !totpSecret.value) await loadTotpSecret();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" :title="$t('profile.enable2FA.title')" :dismissable="!props.mandatory2FA || props.has2FA">
|
||||
<Dialog ref="dialog" :title="setupMode === 'totp' ? $t('profile.enableTotp.title') : $t('profile.enablePasskey.title')" :dismissable="!props.mandatory2FA || props.has2FA">
|
||||
<div>
|
||||
<p class="text-warning" v-if="props.mandatory2FA && !props.has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
|
||||
|
||||
<ButtonGroup style="display: flex; justify-content: center;"> <Button secondary @click="setupMode = 'passkey'" :outline="setupMode !== 'passkey' || null">{{ $t('profile.enable2FA.passkeyOption') }}</Button>
|
||||
<Button secondary @click="setupMode = 'totp'" :outline="setupMode !== 'totp' || null">{{ $t('profile.enable2FA.totpOption') }}</Button>
|
||||
</ButtonGroup>
|
||||
|
||||
<!-- Passkey Setup -->
|
||||
<div v-if="setupMode === 'passkey'">
|
||||
<p v-html="$t('profile.enable2FA.passkeyDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
|
||||
@@ -102,6 +107,9 @@ defineExpose({
|
||||
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
|
||||
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
|
||||
</div>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('totp')">{{ $t('profile.enable2FA.switchToTotp') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- TOTP Setup -->
|
||||
@@ -123,6 +131,9 @@ defineExpose({
|
||||
<div class="error-label" v-if="totpEnableError">{{ totpEnableError }}</div>
|
||||
</FormGroup>
|
||||
</form>
|
||||
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
|
||||
<a href="#" @click.prevent="switchMode('passkey')">{{ $t('profile.enable2FA.switchToPasskey') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, computed, onMounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { ref, computed, onMounted, watch, nextTick, useTemplateRef } from 'vue';
|
||||
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
|
||||
import { useDebouncedRef, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -21,7 +21,7 @@ const refreshBusy = ref(false);
|
||||
const page = ref(1);
|
||||
const perPage = ref(100);
|
||||
const eventlogContainer = useTemplateRef('eventlogContainer');
|
||||
const actions = reactive([]);
|
||||
const actions = ref([]);
|
||||
|
||||
const highlight = useDebouncedRef('', 300);
|
||||
const currentMatchPosition = ref(-1);
|
||||
@@ -107,7 +107,7 @@ async function goToNextMatch() {
|
||||
|
||||
function buildFilter() {
|
||||
const filter = {};
|
||||
if (actions.length) filter.actions = actions.join(',');
|
||||
if (actions.value.length) filter.actions = actions.value.join(',');
|
||||
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
|
||||
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
|
||||
return filter;
|
||||
@@ -142,7 +142,7 @@ function onOpenDateFilter(event) {
|
||||
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
|
||||
}
|
||||
|
||||
watch(actions, onRefresh);
|
||||
watch(actions.value, onRefresh);
|
||||
watch(filterFrom, onRefresh);
|
||||
watch(filterTo, onRefresh);
|
||||
watch(highlight, async () => {
|
||||
@@ -202,8 +202,8 @@ defineExpose({ refresh: onRefresh, setHighlight });
|
||||
<table class="eventlog-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 160px;">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%;">{{ $t('eventlog.source') }}</th>
|
||||
<th style="width: 190px;">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 100px;">{{ $t('eventlog.source') }}</th>
|
||||
<th>{{ $t('eventlog.details') }}</th>
|
||||
<th style="width: 40px;"></th>
|
||||
</tr>
|
||||
@@ -211,10 +211,10 @@ defineExpose({ refresh: onRefresh, setHighlight });
|
||||
<tbody>
|
||||
<template v-for="(eventlog, index) in eventlogs" :key="eventlog.id">
|
||||
<tr :data-index="index" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" @click="eventlog.isOpen = !eventlog.isOpen">
|
||||
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td>{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td class="eventlog-source">{{ eventlog.source }}</td>
|
||||
<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" v-html="eventlog.details"></td>
|
||||
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="`/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></td>
|
||||
<td v-html="eventlog.details"></td>
|
||||
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="app ? `/logs.html?appId=${app.id}&taskId=${eventlog.raw.data.taskId}` : `/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></td>
|
||||
</tr>
|
||||
<tr v-show="eventlog.isOpen">
|
||||
<td colspan="4" class="eventlog-details" @click.stop>
|
||||
@@ -243,6 +243,9 @@ defineExpose({ refresh: onRefresh, setHighlight });
|
||||
.eventlog-table th,
|
||||
.eventlog-table td {
|
||||
padding: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
|
||||
@@ -175,37 +175,66 @@ async function onDrop(targetFolder, dataTransfer, files) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntries(dirReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
function setRelativePath(file, entry) {
|
||||
const relativePath = (entry.fullPath || entry.name || '').replace(/^\//, '');
|
||||
if (relativePath) {
|
||||
// trying with defineProperty() to better mimic native behavior adding a non-enumeratible property
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath });
|
||||
} catch {
|
||||
file.webkitRelativePath = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// wrapper as chrome only returns files in batches of 100 entries
|
||||
async function readAllEntries(dirReader) {
|
||||
const all = [];
|
||||
let batch;
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
all.push(...batch);
|
||||
} while (batch.length > 0);
|
||||
return all;
|
||||
}
|
||||
|
||||
const fileList = [];
|
||||
async function traverseFileTree(item) {
|
||||
if (item.isFile) {
|
||||
fileList.push(await getFile(item));
|
||||
const file = await getFile(item);
|
||||
setRelativePath(file, item);
|
||||
fileList.push(file);
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
const dirReader = item.createReader();
|
||||
const entries = await readEntries(dirReader);
|
||||
const entries = await readAllEntries(dirReader);
|
||||
|
||||
for (const i in entries) {
|
||||
await traverseFileTree(entries[i], item.name);
|
||||
await traverseFileTree(entries[i]);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping uknown file type', item);
|
||||
console.log('Skipping unknown file type', item);
|
||||
}
|
||||
}
|
||||
|
||||
// collect all files to upload
|
||||
for (const item of dataTransfer.items) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (!entry) {
|
||||
const file = item.getAsFile ? item.getAsFile() : null;
|
||||
if (file) fileList.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile) {
|
||||
fileList.push(await getFile(entry));
|
||||
const file = await getFile(entry);
|
||||
setRelativePath(file, entry);
|
||||
fileList.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
await traverseFileTree(entry, sanitize(`${cwd.value}/${targetFolder}`));
|
||||
await traverseFileTree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,10 @@ import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import ServicesModel from '../models/ServicesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
defineProps(['config', 'subscription', 'notificationCount']);
|
||||
defineProps(['config', 'notificationCount']);
|
||||
|
||||
const profile = inject('profile');
|
||||
const subscription = inject('subscription');
|
||||
|
||||
const helpButton = useTemplateRef('helpButton');
|
||||
const helpPopover = useTemplateRef('helpPopover');
|
||||
@@ -115,8 +116,9 @@ onUnmounted(() => {
|
||||
<Icon :icon="'fa fa-exclamation-triangle'"/> {{ $t('main.platform.startupFailed') }}
|
||||
</div>
|
||||
|
||||
<!-- Warnings if subscription is expired or unpaid -->
|
||||
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
||||
<!-- Warnings if subscription is expired, unpaid or canceled -->
|
||||
<a v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" href="/#/cloudron-account">Subscription Expired</a>
|
||||
<a v-else-if="profile.isAtLeastOwner && (subscription.cancel_at || subscription.status === 'canceled')" class="headerbar-action subscription-canceled" href="/#/cloudron-account">Subscription Canceled</a>
|
||||
|
||||
<a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="/#/notifications"><Icon :icon="notificationCount > 0 ? 'fas fa-bell' : 'far fa-bell'"/> {{ notificationCount > 99 ? '99+' : notificationCount }}</a>
|
||||
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
@@ -168,13 +170,16 @@ onUnmounted(() => {
|
||||
border-bottom: 1px solid var(--pankow-input-border-color);
|
||||
}
|
||||
|
||||
.subscription-expired {
|
||||
.subscription-expired,
|
||||
.subscription-canceled {
|
||||
background-color: var(--pankow-color-danger);
|
||||
color: white;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.subscription-expired:hover {
|
||||
.subscription-expired:hover,
|
||||
.subscription-canceled:hover {
|
||||
color: white;
|
||||
background-color: var(--pankow-color-danger-hover);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, InputGroup } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -13,17 +13,16 @@ const password = ref('');
|
||||
const success = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
// https://stackoverflow.com/questions/1497481/javascript-password-generator
|
||||
function onGeneratePassword() {
|
||||
const length = 12;
|
||||
const charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
let tmp = '';
|
||||
|
||||
for (var i = 0, n = charset.length; i < length; ++i) {
|
||||
tmp += charset.charAt(Math.floor(Math.random() * n));
|
||||
function generatePassword() {
|
||||
const blocks = [];
|
||||
const values = new Uint8Array(16);
|
||||
crypto.getRandomValues(values);
|
||||
for (let b = 0; b < 4; b++) {
|
||||
let block = '';
|
||||
for (let i = 0; i < 4; i++) block += String.fromCharCode(97 + (values[b * 4 + i] % 26));
|
||||
blocks.push(block);
|
||||
}
|
||||
|
||||
password.value = tmp;
|
||||
return blocks.join('-');
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -45,7 +44,7 @@ defineExpose({
|
||||
u = JSON.parse(JSON.stringify(u)); // make a copy
|
||||
user.value = u;
|
||||
success.value = false;
|
||||
password.value = '';
|
||||
password.value = generatePassword();
|
||||
formError.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
@@ -71,9 +70,8 @@ defineExpose({
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="passwordInput" v-model="password" style="flex-grow: 1;"/>
|
||||
<ClipboardButton v-if="success" :value="password" />
|
||||
<Button tool v-else @click="onGeneratePassword()" v-tooltip="$t('users.setGhostDialog.generatePassword')" icon="fa fa-key" />
|
||||
<TextInput id="passwordInput" v-model="password" readonly style="flex-grow: 1;"/>
|
||||
<ClipboardButton :value="password" />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -62,7 +62,11 @@ onMounted(async () => {
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId) {
|
||||
if (appId && taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
@@ -89,7 +93,7 @@ onMounted(async () => {
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value);
|
||||
logsModel = LogsModel.create(type.value, id.value, { appId });
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
@@ -205,7 +209,7 @@ body {
|
||||
color: white;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
white-space: pre-wrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ const appUpp = ref(false);
|
||||
const appDown = ref(false);
|
||||
const appOutOfMemory = ref(false);
|
||||
const backupFailed = ref(false);
|
||||
const appAutoUpdateFailed = ref(false);
|
||||
const certificateRenewalFailed = ref(false);
|
||||
const diskSpace = ref(false);
|
||||
const cloudronUpdateFailed = ref(false);
|
||||
const manualUpdateRequired = ref(false);
|
||||
const reboot = ref(false);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -26,9 +28,11 @@ async function onSubmit() {
|
||||
if (appDown.value) config.push('appDown');
|
||||
if (appOutOfMemory.value) config.push('appOutOfMemory');
|
||||
if (backupFailed.value) config.push('backupFailed');
|
||||
if (appAutoUpdateFailed.value) config.push('appAutoUpdateFailed');
|
||||
if (certificateRenewalFailed.value) config.push('certificateRenewalFailed');
|
||||
if (diskSpace.value) config.push('diskSpace');
|
||||
if (cloudronUpdateFailed.value) config.push('cloudronUpdateFailed');
|
||||
if (manualUpdateRequired.value) config.push('manualUpdateRequired');
|
||||
if (reboot.value) config.push('reboot');
|
||||
|
||||
const [error] = await profileModel.setNotificationConfig(config);
|
||||
@@ -49,9 +53,11 @@ async function open() {
|
||||
appDown.value = config.indexOf('appDown') !== -1;
|
||||
appOutOfMemory.value = config.indexOf('appOutOfMemory') !== -1;
|
||||
backupFailed.value = config.indexOf('backupFailed') !== -1;
|
||||
appAutoUpdateFailed.value = config.indexOf('appAutoUpdateFailed') !== -1;
|
||||
certificateRenewalFailed.value = config.indexOf('certificateRenewalFailed') !== -1;
|
||||
diskSpace.value = config.indexOf('diskSpace') !== -1;
|
||||
cloudronUpdateFailed.value = config.indexOf('cloudronUpdateFailed') !== -1;
|
||||
manualUpdateRequired.value = config.indexOf('manualUpdateRequired') !== -1;
|
||||
reboot.value = config.indexOf('reboot') !== -1;
|
||||
|
||||
dialogItem.value.open();
|
||||
@@ -98,6 +104,11 @@ defineExpose({
|
||||
<Switch v-model="backupFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.appAutoUpdateFailed') }}</div>
|
||||
<Switch v-model="appAutoUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.certificateRenewalFailed') }}</div>
|
||||
<Switch v-model="certificateRenewalFailed" :disabled="busy"/>
|
||||
@@ -113,6 +124,11 @@ defineExpose({
|
||||
<Switch v-model="cloudronUpdateFailed" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.manualUpdateRequired') }}</div>
|
||||
<Switch v-model="manualUpdateRequired" :disabled="busy"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<div>{{ $t('notifications.settings.rebootRequired') }}</div>
|
||||
<Switch v-model="reboot" :disabled="busy"/>
|
||||
|
||||
@@ -105,6 +105,7 @@ onUnmounted(() => {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 15px;
|
||||
padding-bottom: 25px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header-title-badge {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { marked } from 'marked';
|
||||
import { Button, PasswordInput, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
import { startAuthFlow } from '../utils.js';
|
||||
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
@@ -46,7 +47,7 @@ function validateForm() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
if (!form.value.reportValidity() || !isFormValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -89,7 +90,7 @@ async function onSubmit() {
|
||||
// set token to autologin on first oidc flow
|
||||
localStorage.cloudronFirstTimeToken = result.accessToken;
|
||||
|
||||
dashboardUrl.value = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
dashboardUrl.value = await startAuthFlow('cid-webadmin', '');
|
||||
|
||||
busy.value = false;
|
||||
mode.value = MODE.DONE;
|
||||
|
||||
@@ -7,14 +7,14 @@ defineProps({
|
||||
},
|
||||
state: {
|
||||
validator(value) {
|
||||
// The value must match one of these strings
|
||||
return ['success', 'warning', 'danger', ''].includes(value);
|
||||
return ['success', 'warning', 'danger', 'idle', ''].includes(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function color(state) {
|
||||
if (state === 'success') return '#27CE65';
|
||||
else if (state === 'idle') return '#BCD0C3';
|
||||
else if (state === 'warning') return '#f0ad4e';
|
||||
else if (state === 'danger') return '#d9534f';
|
||||
else return '#7c7c7c';
|
||||
|
||||
@@ -336,8 +336,7 @@ defineExpose({ refresh });
|
||||
<template #integrity="{ item:backup }">
|
||||
<Spinner v-if="backup.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="backup.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i v-if="backup.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle" v-tooltip="prettyLongDate(backup.lastIntegrityCheckTime)"></i>
|
||||
<i v-else class="fa-solid fa-times-circle" v-tooltip="prettyLongDate(backup.lastIntegrityCheckTime)"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(backup.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
|
||||
@@ -30,7 +30,8 @@ const taskLogsMenu = ref([]);
|
||||
const apps = ref([]);
|
||||
const version = ref('');
|
||||
const ubuntuVersion = ref('');
|
||||
const currentPattern = ref('');
|
||||
const currentSchedule = ref('');
|
||||
const currentPolicy = ref('');
|
||||
const updateBusy = ref(false);
|
||||
const updateError = ref({});
|
||||
const stopError = ref({});
|
||||
@@ -55,17 +56,16 @@ const inProgressApps = computed(() => {
|
||||
const configureDialog = useTemplateRef('configureDialog');
|
||||
const configureBusy = ref(false);
|
||||
const configureError = ref('');
|
||||
const configureType = ref('');
|
||||
const configurePattern = ref('');
|
||||
const configurePolicy = ref('');
|
||||
const configureDays = ref([]);
|
||||
const configureHours = ref([]);
|
||||
|
||||
async function refreshAutoupdatePattern() {
|
||||
const [error, result] = await updaterModel.getAutoupdatePattern();
|
||||
async function refreshAutoupdateConfig() {
|
||||
const [error, result] = await updaterModel.getAutoupdateConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
currentPattern.value = result.pattern;
|
||||
configurePattern.value = result.pattern;
|
||||
currentSchedule.value = result.schedule;
|
||||
currentPolicy.value = result.policy;
|
||||
}
|
||||
|
||||
async function refreshApps() {
|
||||
@@ -87,21 +87,22 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
if (currentPattern.value === 'never') {
|
||||
configureType.value = 'never';
|
||||
} else {
|
||||
configureType.value = 'pattern';
|
||||
const result = parseSchedule(currentPattern.value);
|
||||
configureDays.value = result.days; // Array of cronDays.id
|
||||
configureHours.value = result.hours; // Array of cronHours.id
|
||||
configurePolicy.value = currentPolicy.value || 'never';
|
||||
|
||||
if (currentPolicy.value !== 'never') {
|
||||
const result = parseSchedule(currentSchedule.value);
|
||||
configureDays.value = result.days;
|
||||
configureHours.value = result.hours;
|
||||
}
|
||||
|
||||
configureDialog.value.open();
|
||||
}
|
||||
|
||||
async function onSubmitConfigure() {
|
||||
let pattern = 'never';
|
||||
if (configureType.value === 'pattern') {
|
||||
let schedule = currentSchedule.value || '00 00 1,3,5,23 * * *';
|
||||
const policy = configurePolicy.value;
|
||||
|
||||
if (policy !== 'never') {
|
||||
let daysPattern;
|
||||
if (configureDays.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = configureDays.value.join(',');
|
||||
@@ -110,18 +111,18 @@ async function onSubmitConfigure() {
|
||||
if (configureHours.value.length === 24) hoursPattern = '*';
|
||||
else hoursPattern = configureHours.value.join(',');
|
||||
|
||||
pattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
schedule = '00 00 ' + hoursPattern + ' * * ' + daysPattern;
|
||||
}
|
||||
|
||||
configureBusy.value = true;
|
||||
const [error] = await updaterModel.setAutoupdatePattern(pattern);
|
||||
const [error] = await updaterModel.setAutoupdateConfig(schedule, policy);
|
||||
if (error) {
|
||||
configureError.value = error.body ? error.body.message : 'Internal error';
|
||||
configureBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
|
||||
configureBusy.value = false;
|
||||
configureDialog.value.close();
|
||||
@@ -239,7 +240,7 @@ onMounted(async () => {
|
||||
ubuntuVersion.value = result.ubuntuVersion;
|
||||
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
@@ -288,25 +289,35 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Dialog ref="configureDialog"
|
||||
:title="$t('settings.updateScheduleDialog.title')"
|
||||
:title="$t('settings.configureUpdates.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="configureType === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-active="configurePolicy === 'never' ? true : (configureHours.length > 0 && configureDays.length > 0)"
|
||||
:confirm-busy="configureBusy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!configureBusy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitConfigure()"
|
||||
>
|
||||
<FormGroup>
|
||||
<div description v-html="$t('settings.updateScheduleDialog.description')"></div>
|
||||
|
||||
<p class="has-error text-center" v-show="configureError">{{ configureError }}</p>
|
||||
|
||||
<Radiobutton v-model="configureType" value="never" :label="$t('settings.updateScheduleDialog.disableCheckbox')" />
|
||||
<Radiobutton v-model="configureType" value="pattern" :label="$t('settings.updateScheduleDialog.enableCheckbox')"/>
|
||||
<label>{{ $t('settings.configureUpdates.policy') }}</label>
|
||||
<div>{{ $t('settings.configureUpdates.policyDescription') }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="configurePolicy" value="never" :label="$t('settings.updates.disabled')" />
|
||||
<Radiobutton v-model="configurePolicy" value="apps_only" :label="$t('settings.updates.appsOnly')" />
|
||||
<Radiobutton v-model="configurePolicy" value="platform_and_apps" :label="$t('settings.updates.platformAndApps')" />
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<div v-show="configureType === 'pattern'" style="display: flex; gap: 10px; align-items: center; margin: 10px 0px 0px 25px">
|
||||
<div>{{ $t('settings.updateScheduleDialog.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.updateScheduleDialog.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="configureType === 'pattern' && !(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
<FormGroup>
|
||||
<div v-show="configurePolicy !== 'never'">
|
||||
<label>{{ $t('settings.configureUpdates.schedule') }}</label>
|
||||
<div style="display: flex; gap: 10px; align-items: center; margin-top: 12px">
|
||||
<div>{{ $t('settings.configureUpdates.days') }}: <MultiSelect v-model="configureDays" :options="cronDays" option-label="name" option-key="id"/></div>
|
||||
<div>{{ $t('settings.configureUpdates.hours') }}: <MultiSelect v-model="configureHours" :options="cronHours" option-label="name" option-key="id"/></div>
|
||||
<div class="text-small text-danger" v-show="!(configureHours.length !== 0 && configureDays.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</Dialog>
|
||||
@@ -321,9 +332,10 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.disabled') }}</span>
|
||||
<label>{{ $t('settings.updates.config') }}</label>
|
||||
<span v-if="currentPolicy === 'never'">{{ $t('settings.updates.disabled') }}</span>
|
||||
<span v-else-if="currentPolicy === 'apps_only'">{{ $t('settings.updates.appsOnly') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.platformAndApps') }} - {{ prettySchedule(currentSchedule) }}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button tool plain @click="onShowConfigure()">{{ $t('main.dialog.edit') }}</Button>
|
||||
|
||||
@@ -91,7 +91,7 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading">
|
||||
<div v-show="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :sso="app.sso" :installation="false"/>
|
||||
<div style="padding-top: 10px"></div>
|
||||
|
||||
@@ -15,15 +15,12 @@ import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import BackupSitesModel from '../../models/BackupSitesModel.js';
|
||||
import BackupsModel from '../../models/BackupsModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
import { TASK_TYPES } from '../../constants.js';
|
||||
import BackupInfoDialog from '../BackupInfoDialog.vue';
|
||||
import ActionBar from '../../components/ActionBar.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
@@ -141,7 +138,7 @@ async function onChangeAutoBackups(value) {
|
||||
async function waitForTask() {
|
||||
if (!lastTask.value.id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(lastTask.value.id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result;
|
||||
@@ -158,7 +155,7 @@ async function waitForTask() {
|
||||
}
|
||||
|
||||
async function refreshTasks() {
|
||||
const [error, result] = await tasksModel.getByType(TASK_TYPES.TASK_APP_BACKUP_PREFIX + props.app.id);
|
||||
const [error, result] = await appsModel.listTasks(props.app.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
lastTask.value = result[0] || {};
|
||||
@@ -168,7 +165,7 @@ async function refreshTasks() {
|
||||
return {
|
||||
icon: 'fa-solid ' + ((!t.active && t.success) ? 'status-active fa-check-circle' : (t.active ? 'fa-circle-notch fa-spin' : 'status-error fa-times-circle')),
|
||||
label: prettyLongDate(t.ts),
|
||||
action: () => { window.open(`/logs.html?taskId=${t.id}`); }
|
||||
action: () => { window.open(`/logs.html?appId=${props.app.id}&taskId=${t.id}`); }
|
||||
};
|
||||
});
|
||||
|
||||
@@ -188,7 +185,7 @@ async function onStartBackup(backupSiteId) {
|
||||
async function onStopBackup() {
|
||||
stopBackupBusy.value = true;
|
||||
|
||||
const [error] = await tasksModel.stop(lastTask.value.id);
|
||||
const [error] = await appsModel.stopAppTask(props.app.id, lastTask.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refreshTasks();
|
||||
@@ -207,6 +204,7 @@ function onEdit(backup) {
|
||||
editLabel.value = backup.label || '';
|
||||
editError.value = '';
|
||||
editDialog.value.open();
|
||||
setTimeout(() => document.getElementById('labelInput').focus(), 500);
|
||||
}
|
||||
|
||||
async function onEditSubmit() {
|
||||
@@ -413,7 +411,7 @@ onUnmounted(() => {
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
<ProgressBar :value="lastTask.percent" :show-label="false" :busy="true" :mode="lastTask.percent <= 0 ? 'indeterminate' : ''"/>
|
||||
<a :href="`/logs.html?taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
<a :href="`/logs.html?appId=${props.app.id}&taskId=${lastTask.id}`" target="_blank" style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ lastTask.percent }}% {{ lastTask.message }}</a>
|
||||
</div>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onStopBackup()" :loading="stopBackupBusy" :disabled="stopBackupBusy"></Button>
|
||||
</div>
|
||||
@@ -442,8 +440,7 @@ onUnmounted(() => {
|
||||
<template #integrity="{ item }">
|
||||
<Spinner v-if="item.integrityCheckTask?.active" style="min-width: 0;"/>
|
||||
<div v-else-if="item.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
|
||||
<i v-if="item.integrityCheckStatus === 'passed'" class="fa-solid fa-check-circle" v-tooltip="prettyLongDate(item.lastIntegrityCheckTime)"></i>
|
||||
<i v-else class="fa-solid fa-times-circle" v-tooltip="prettyLongDate(item.lastIntegrityCheckTime)"></i>
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': item.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': item.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': item.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(item.lastIntegrityCheckTime)"></i>
|
||||
</div>
|
||||
<div v-else style="text-align: center;">-</div>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, Radiobutton, InputGroup, FormGroup, TextInput, SingleSelect } from '@cloudron/pankow';
|
||||
@@ -55,6 +55,7 @@ async function onSendmailSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
sendmailBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -78,6 +79,7 @@ async function onRecvmailSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
recvmailBusy.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import PortBindings from '../PortBindings.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import DomainsModel from '../../models/DomainsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
@@ -18,7 +18,7 @@ const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const errorObject = ref({});
|
||||
const overwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref([]);
|
||||
const domain = ref('');
|
||||
const subdomain = ref('');
|
||||
const secondaryDomains = ref({});
|
||||
@@ -56,6 +56,12 @@ function onAddRedirect() {
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function resetDnsOverwrite() {
|
||||
needsOverwriteDns.value = [];
|
||||
overwriteDns.value = false;
|
||||
errorMessage.value = '';
|
||||
}
|
||||
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
@@ -87,7 +93,7 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
errorMessage.value = '';
|
||||
errorObject.value = {};
|
||||
needsOverwriteDns.value = false;
|
||||
needsOverwriteDns.value = [];
|
||||
|
||||
const checkForDomains = [{
|
||||
domain: domain.value,
|
||||
@@ -98,6 +104,7 @@ async function onSubmit() {
|
||||
for (const d of aliases.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
|
||||
|
||||
const conflicting = [];
|
||||
for (const d of checkForDomains) {
|
||||
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
|
||||
if (error) {
|
||||
@@ -106,16 +113,19 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
if (result.needsOverwrite && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = true;
|
||||
return;
|
||||
}
|
||||
if (result.needsOverwrite) conflicting.push((d.subdomain ? d.subdomain + '.' : '') + d.domain);
|
||||
}
|
||||
|
||||
if (conflicting.length > 0 && !overwriteDns.value) {
|
||||
busy.value = false;
|
||||
needsOverwriteDns.value = conflicting;
|
||||
errorMessage.value = `DNS records of ${conflicting.join(', ')} already exist`;
|
||||
return;
|
||||
}
|
||||
|
||||
// only use enabled ports
|
||||
const ports = {};
|
||||
const portsCombined = Object.assign(tcpPorts.value || {}, udpPorts.value || {});
|
||||
const portsCombined = Object.assign({}, tcpPorts.value || {}, udpPorts.value || {});
|
||||
for (const env in portsCombined) {
|
||||
if (portsCombined[env].enabled) {
|
||||
ports[env] = portsCombined[env].value;
|
||||
@@ -139,6 +149,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await props.refreshApp();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
@@ -206,8 +217,8 @@ onMounted(async () => {
|
||||
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" :search-threshold="10" required/>
|
||||
<TextInput style="flex-grow: 1" v-model="subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((subdomain ? subdomain + '.' : '') + domain) })"></div>
|
||||
<!-- Button just to offset the same margin on the right to align location input when alias or redirects are visible -->
|
||||
@@ -219,8 +230,8 @@ onMounted(async () => {
|
||||
<label :for="'secondaryDomainInput' + item.containerPort">{{ item.title }}</label>
|
||||
<small>{{ item.description }}</small>
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10" required/>
|
||||
<TextInput style="flex-grow: 1" :id="'secondaryDomainInput' + item.containerPort" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('appstore.installDialog.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="isNoopOrManual(item.domain)" v-html="$t('appstore.installDialog.manualWarning', { location: ((item.subdomain ? item.subdomain + '.' : '') + item.domain) })"></div>
|
||||
</FormGroup>
|
||||
@@ -233,8 +244,8 @@ onMounted(async () => {
|
||||
<div v-for="(item, index) in aliases" :key="item" style="margin-bottom: 10px">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10"/>
|
||||
</InputGroup>
|
||||
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveAlias(index)"/>
|
||||
</div>
|
||||
@@ -252,8 +263,8 @@ onMounted(async () => {
|
||||
<div v-for="(item, index) in redirects" :key="item" style="margin-bottom: 10px;">
|
||||
<div style="display: flex; gap: 10px;">
|
||||
<InputGroup style="flex-grow: 1">
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" :search-threshold="10"/>
|
||||
<TextInput style="flex-grow: 1" v-model="item.subdomain" @input="resetDnsOverwrite()" :placeholder="$t('app.location.locationPlaceholder')"/>
|
||||
<SingleSelect :disabled="busy" :options="domains" v-model="item.domain" option-key="domain" option-label="label" @select="resetDnsOverwrite()" :search-threshold="10"/>
|
||||
</InputGroup>
|
||||
<Button danger tool :disabled="busy" icon="fa-solid fa-trash" @click="onRemoveRedirect(index)"/>
|
||||
</div>
|
||||
@@ -271,13 +282,11 @@ onMounted(async () => {
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<br v-if="errorMessage"/>
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<Checkbox v-if="needsOverwriteDns.length" v-model="overwriteDns" :label="$t('app.location.overwriteDns', { domains: needsOverwriteDns.join(', ') })"/>
|
||||
<br v-if="needsOverwriteDns.length"/>
|
||||
|
||||
<Checkbox v-if="needsOverwriteDns" v-model="overwriteDns" :label="$t('app.location.dnsoverwrite')"/>
|
||||
<br v-if="needsOverwriteDns"/>
|
||||
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !isFormValid">{{ $t('app.location.saveAction') }}</Button>
|
||||
<Button @click="onSubmit()" :loading="busy" :disabled="busy || (app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !isFormValid || (needsOverwriteDns.length > 0 && !overwriteDns)">{{ $t('app.location.saveAction') }}</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { taskNameFromInstallationState } from '../../utils.js';
|
||||
import { ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const busyRepair = ref(false);
|
||||
@@ -28,8 +28,8 @@ async function onToggleDebugMode() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
// let the task start
|
||||
setTimeout(() => { debugModeBusy.value = false; }, 4000);
|
||||
await props.refreshApp();
|
||||
debugModeBusy.value = false;
|
||||
}
|
||||
|
||||
async function onRepair() {
|
||||
@@ -42,7 +42,8 @@ async function onRepair() {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => { busyRepair.value = false; }, 4000);
|
||||
await props.refreshApp();
|
||||
busyRepair.value = false;
|
||||
}
|
||||
|
||||
async function onRestart() {
|
||||
|
||||
@@ -10,7 +10,7 @@ import SystemModel from '../../models/SystemModel.js';
|
||||
const appsModel = AppsModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const memoryLimitBusy = ref(false);
|
||||
const memoryLimit = ref(0);
|
||||
@@ -33,8 +33,8 @@ async function onSubmitMemoryLimit() {
|
||||
const [error] = await appsModel.configure(props.app.id, 'memory_limit', { memoryLimit: limit });
|
||||
if (error) return console.error(error);
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => memoryLimitBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
memoryLimitBusy.value = false;
|
||||
}
|
||||
|
||||
async function onSubmitCpuQuota() {
|
||||
@@ -44,9 +44,8 @@ async function onSubmitCpuQuota() {
|
||||
if (error) return console.error(error);
|
||||
|
||||
currentCpuQuota.value = parseInt(cpuQuota.value);
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => cpuQuotaBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
cpuQuotaBusy.value = false;
|
||||
}
|
||||
|
||||
async function onSubmitDevices() {
|
||||
@@ -70,11 +69,9 @@ async function onSubmitDevices() {
|
||||
return;
|
||||
}
|
||||
|
||||
// give polling some time
|
||||
setTimeout(() => {
|
||||
devicesBusy.value = false;
|
||||
currentDevices.value = Object.keys(devs);
|
||||
}, 4000);
|
||||
currentDevices.value = Object.keys(devs);
|
||||
await props.refreshApp();
|
||||
devicesBusy.value = false;
|
||||
}
|
||||
|
||||
const devicesChanged = computed(() => {
|
||||
@@ -116,20 +113,20 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('app.resources.memory.title') }} <sup><a href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ prettyBinarySize(memoryLimit, 'Default (256 MiB)') }}</b></label>
|
||||
<div description>{{ $t('app.resources.memory.description') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" />
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" step="134217728" :min="memoryTicks[0]" :max="memoryTicks[memoryTicks.length-1]" list="memoryLimitTicks" :disabled="memoryLimitBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId" />
|
||||
<datalist id="memoryLimitTicks">
|
||||
<option v-for="value of memoryTicks" :key="value" :value="value"></option>
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit === currentMemoryLimit) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
<Button @click="onSubmitMemoryLimit()" :loading="memoryLimitBusy" :disabled="memoryLimitBusy || (!app.error && memoryLimit == currentMemoryLimit) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId">{{ $t('app.resources.memory.resizeAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="cpuQuotaInput">{{ $t('app.resources.cpu.title') }} <sup><a href="https://docs.cloudron.io/apps/#cpu-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ cpuQuota + ' %' }}</b></label>
|
||||
<div description>{{ $t('app.resources.cpu.description') }}</div>
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" />
|
||||
<input type="range" id="cpuQuotaInput" v-model="cpuQuota" step="1" min="1" max="100" list="cpuQuotaTicks" :disabled="cpuQuotaBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId" />
|
||||
<datalist id="cpuQuotaTicks">
|
||||
<option value="25"></option>
|
||||
<option value="50"></option>
|
||||
@@ -137,12 +134,12 @@ onMounted(async () => {
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
<br/>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota === currentCpuQuota) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
<Button @click="onSubmitCpuQuota()" :loading="cpuQuotaBusy" :disabled="cpuQuotaBusy || (!app.error && cpuQuota == currentCpuQuota) || (app.error && app.error.installationState !== ISTATES.PENDING_RESIZE) || !!app.taskId">{{ $t('app.resources.cpu.setAction') }}</Button>
|
||||
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
<form @submit.prevent="onSubmitDevices()" autocomplete="off">
|
||||
<fieldset :disabled="devicesBusy || (!app.error && !devicesChanged) || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || app.taskId">
|
||||
<fieldset :disabled="devicesBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId">
|
||||
<input style="display: none;" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="devicesInput">{{ $t('app.resources.devices.label') }} <sup><a href="https://docs.cloudron.io/apps/#devices" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ISTATES } from '../../constants.js';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
|
||||
const { app } = defineProps([ 'app' ]);
|
||||
const { app, refreshApp } = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
@@ -24,6 +24,7 @@ async function onTurnChange(value) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshApp();
|
||||
turnBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ async function onRedisChange(value) {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refreshApp();
|
||||
redisBusy.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import { ISTATES } from '../../constants.js';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import VolumesModel from '../../models/VolumesModel.js';
|
||||
|
||||
const props = defineProps([ 'app' ]);
|
||||
const props = defineProps([ 'app', 'refreshApp' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const volumesModel = VolumesModel.create();
|
||||
@@ -56,9 +56,8 @@ async function onSubmitMove() {
|
||||
}
|
||||
|
||||
originalVolumeId.value = volumeId.value;
|
||||
|
||||
// give app refresh some time, ideally we wait for the task
|
||||
setTimeout(() => moveBusy.value = false, 4000);
|
||||
await props.refreshApp();
|
||||
moveBusy.value = false;
|
||||
}
|
||||
|
||||
function onMountAdd() {
|
||||
@@ -90,10 +89,9 @@ async function onSubmitMounts() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
// make a copy, cannot clone due to Proxy objects
|
||||
originalMounts.value = mounts.value.map(m => { return { volumeId: m.volumeId, readOnly: m.readOnly }; });
|
||||
|
||||
setTimeout(() => mountsBusy.value = false, 2000);
|
||||
await props.refreshApp();
|
||||
mountsBusy.value = false;
|
||||
}
|
||||
|
||||
const mountsValid = computed(() => {
|
||||
|
||||
@@ -26,11 +26,14 @@ async function onUninstall() {
|
||||
confirmLabel: t('app.uninstallDialog.uninstallAction'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
autoCloseOnConfirm: false,
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await appsModel.uninstall(props.app.id);
|
||||
inputDialog.value.close();
|
||||
|
||||
if (error) return console.error(error);
|
||||
|
||||
window.location.href = '/#/apps';
|
||||
@@ -44,12 +47,15 @@ async function onArchive() {
|
||||
message: t('app.archiveDialog.description', { app: (props.app.label || props.app.fqdn), date: prettyLongDate(latestBackup.value.creationTime) }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('app.archive.action'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
autoCloseOnConfirm: false,
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await appsModel.archive(props.app.id, latestBackup.value.id);
|
||||
inputDialog.value.close();
|
||||
|
||||
if (error) return console.error(error);
|
||||
|
||||
window.location.href = '/#/apps';
|
||||
|
||||
@@ -7,13 +7,11 @@ import { ISTATES } from '../../constants.js';
|
||||
import SettingsItem from '../SettingsItem.vue';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
|
||||
const props = defineProps([ 'app', 'refresh-app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
@@ -41,7 +39,7 @@ async function onAutoUpdatesEnabledChange(value) {
|
||||
async function waitForTask(id) {
|
||||
if (!id) return;
|
||||
|
||||
const [error, result] = await tasksModel.get(id);
|
||||
const [error, result] = await appsModel.getAppTask(props.app.id, id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
// task done, refresh menu
|
||||
|
||||
@@ -293,11 +293,9 @@ const STORAGE_PROVIDERS = [
|
||||
{ name: 'Cloudflare R2', value: 'cloudflare-r2' },
|
||||
{ name: 'Contabo Object Storage', value: 'contabo-objectstorage', regions: REGIONS_CONTABO },
|
||||
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces', regions: REGIONS_DIGITALOCEAN },
|
||||
{ name: 'External/Local Disk (EXT4 or XFS)', value: 'disk' },
|
||||
{ name: 'EXT4 Disk', value: 'ext4' },
|
||||
{ name: 'Exoscale SOS', value: 'exoscale-sos', regions: REGIONS_EXOSCALE },
|
||||
{ name: 'Filesystem', value: 'filesystem' },
|
||||
{ name: 'Filesystem (Mount point)', value: 'mountpoint' }, // legacy
|
||||
{ name: 'Google Cloud Storage', value: 'gcs' },
|
||||
{ name: 'Hetzner Object Storage', value: 'hetzner-objectstorage', regions: REGIONS_HETZNER },
|
||||
{ name: 'IDrive e2', value: 'idrive-e2' },
|
||||
@@ -314,6 +312,7 @@ const STORAGE_PROVIDERS = [
|
||||
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage', regions: REGIONS_VULTR },
|
||||
{ name: 'Wasabi', value: 'wasabi', regions: REGIONS_WASABI },
|
||||
{ name: 'XFS Disk', value: 'xfs' },
|
||||
{ name: 'User-managed Mount Point', value: 'mountpoint' },
|
||||
];
|
||||
|
||||
const BACKUP_FORMATS = [
|
||||
|
||||
@@ -5,8 +5,9 @@ import { API_ORIGIN } from './constants.js';
|
||||
const translations = {};
|
||||
|
||||
const i18n = createI18n({
|
||||
locale: 'en', // set locale
|
||||
fallbackLocale: 'en', // set fallback locale
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
messages: translations,
|
||||
warnHtmlInMessage: 'off',
|
||||
// will replace our double {{}} to vue-i18n single brackets
|
||||
@@ -45,12 +46,7 @@ async function main() {
|
||||
|
||||
if (locale && locale !== 'en') {
|
||||
await loadLanguage(locale);
|
||||
|
||||
if (i18n.mode === 'legacy') {
|
||||
i18n.global.locale = locale;
|
||||
} else {
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
i18n.global.locale.value = locale;
|
||||
}
|
||||
|
||||
return i18n;
|
||||
@@ -68,7 +64,7 @@ async function setLanguage(lang, profile = false) {
|
||||
console.error(`Failed to load language file for ${lang}`, e);
|
||||
}
|
||||
|
||||
i18n.global.locale = lang;
|
||||
i18n.global.locale.value = lang;
|
||||
}
|
||||
|
||||
export default main;
|
||||
|
||||
@@ -172,6 +172,39 @@ function create() {
|
||||
return {
|
||||
name: 'AppsModel',
|
||||
getTask,
|
||||
async listTasks(appId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.tasks];
|
||||
},
|
||||
async getAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async stopAppTask(appId, taskId) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${appId}/tasks/${taskId}/stop`, {}, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 204) return [error || result];
|
||||
return [null];
|
||||
},
|
||||
async install(appData, config) {
|
||||
const data = {
|
||||
subdomain: config.subdomain,
|
||||
|
||||
@@ -25,7 +25,7 @@ function ab2str(buf) {
|
||||
return String.fromCharCode.apply(null, new Uint16Array(buf));
|
||||
}
|
||||
|
||||
export function create(type, id) {
|
||||
export function create(type, id, options = {}) {
|
||||
const accessToken = localStorage.token;
|
||||
const INITIAL_STREAM_LINES = 100;
|
||||
|
||||
@@ -46,6 +46,9 @@ export function create(type, id) {
|
||||
} else if (type === 'service') {
|
||||
streamApi = `/api/v1/services/${id}/logstream`;
|
||||
downloadApi = `/api/v1/services/${id}/logs`;
|
||||
} else if (type === 'task' && options.appId) {
|
||||
streamApi = `/api/v1/apps/${options.appId}/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/apps/${options.appId}/tasks/${id}/logs`;
|
||||
} else if (type === 'task') {
|
||||
streamApi = `/api/v1/tasks/${id}/logstream`;
|
||||
downloadApi = `/api/v1/tasks/${id}/logs`;
|
||||
@@ -79,7 +82,8 @@ export function create(type, id) {
|
||||
}
|
||||
|
||||
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
|
||||
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
const escaped = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
const html = escaped.replace(/\n/g, '<br>');
|
||||
|
||||
eventSource._lastMessage = { time, html };
|
||||
lineHandler(time, html);
|
||||
|
||||
@@ -234,17 +234,6 @@ function create() {
|
||||
if (error || result.status !== 201) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async getPasskey() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/profile/passkey`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.passkey];
|
||||
},
|
||||
async getPasskeyRegistrationOptions() {
|
||||
let error, result;
|
||||
try {
|
||||
|
||||
@@ -17,10 +17,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body.update];
|
||||
},
|
||||
async getAutoupdatePattern() {
|
||||
async getAutoupdateConfig() {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { access_token: accessToken });
|
||||
result = await fetcher.get(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
@@ -28,10 +28,10 @@ function create() {
|
||||
if (error || result.status !== 200) return [error || result];
|
||||
return [null, result.body];
|
||||
},
|
||||
async setAutoupdatePattern(pattern) {
|
||||
async setAutoupdateConfig(schedule, policy) {
|
||||
let error, result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_pattern`, { pattern }, { access_token: accessToken });
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/updater/autoupdate_config`, { schedule, policy }, { access_token: accessToken });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
|
||||
@@ -199,7 +199,7 @@ function create() {
|
||||
if (result.status !== 200) return [result];
|
||||
return [null, result.body.inviteLink];
|
||||
},
|
||||
async disableTwoFactorAuthentication(id) {
|
||||
async disableTotp(id) {
|
||||
let result;
|
||||
try {
|
||||
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/totp_disable`, {}, { access_token: accessToken });
|
||||
|
||||
@@ -6,10 +6,10 @@ const mountTypes = [
|
||||
{ name: 'CIFS', value: 'cifs' },
|
||||
{ name: 'EXT4', value: 'ext4' },
|
||||
{ name: 'Filesystem', value: 'filesystem' },
|
||||
{ name: 'Filesystem (Mount point)', value: 'mountpoint' },
|
||||
{ name: 'NFS', value: 'nfs' },
|
||||
{ name: 'SSHFS', value: 'sshfs' },
|
||||
{ name: 'XFS', value: 'xfs' },
|
||||
{ name: 'User-managed Mount Point', value: 'mountpoint' },
|
||||
];
|
||||
|
||||
function filterConfigForMountType(mountType, config) {
|
||||
|
||||
16
dashboard/src/oidcdeviceconfirm.js
Normal file
16
dashboard/src/oidcdeviceconfirm.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '@fontsource/inter';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import OidcDeviceConfirmView from './views/OidcDeviceConfirmView.vue';
|
||||
|
||||
import './style.css';
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(OidcDeviceConfirmView);
|
||||
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
16
dashboard/src/oidcdeviceinput.js
Normal file
16
dashboard/src/oidcdeviceinput.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '@fontsource/inter';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import OidcDeviceInputView from './views/OidcDeviceInputView.vue';
|
||||
|
||||
import './style.css';
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(OidcDeviceInputView);
|
||||
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
16
dashboard/src/oidcdevicesuccess.js
Normal file
16
dashboard/src/oidcdevicesuccess.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
|
||||
import '@fontsource/inter';
|
||||
|
||||
import i18n from './i18n.js';
|
||||
import OidcDeviceSuccessView from './views/OidcDeviceSuccessView.vue';
|
||||
|
||||
import './style.css';
|
||||
|
||||
(async function init() {
|
||||
const app = createApp(OidcDeviceSuccessView);
|
||||
|
||||
app.use(await i18n());
|
||||
|
||||
app.mount('#app');
|
||||
})();
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { RELAY_PROVIDERS, ISTATES, STORAGE_PROVIDERS, EVENTS } from './constants.js';
|
||||
import { RELAY_PROVIDERS, ISTATES, EVENTS } from './constants.js';
|
||||
import { Marked } from 'marked';
|
||||
|
||||
function safeMarked() {
|
||||
@@ -43,7 +43,7 @@ function download(filename, text) {
|
||||
}
|
||||
|
||||
function mountlike(provider) {
|
||||
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
|
||||
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
|
||||
}
|
||||
|
||||
function s3like(provider) {
|
||||
@@ -55,39 +55,6 @@ function s3like(provider) {
|
||||
|| provider === 'contabo-objectstorage' || provider === 'synology-c2-objectstorage';
|
||||
}
|
||||
|
||||
function regionName(provider, endpoint) {
|
||||
const storageProvider = STORAGE_PROVIDERS.find(sp => sp.value === provider);
|
||||
const regions = storageProvider.regions;
|
||||
if (!regions) return endpoint;
|
||||
const region = regions.find(r => r.value === endpoint);
|
||||
if (!region) return endpoint;
|
||||
return region.name;
|
||||
}
|
||||
|
||||
function prettySiteLocation(site) {
|
||||
switch (site.provider) {
|
||||
case 'filesystem':
|
||||
return site.config.backupDir + (site.config.prefix ? `/${site.config.prefix}` : '');
|
||||
case 'disk':
|
||||
case 'ext4':
|
||||
case 'xfs':
|
||||
case 'mountpoint':
|
||||
return (site.config.mountOptions.diskPath || site.config.mountPoint) + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'cifs':
|
||||
case 'nfs':
|
||||
case 'sshfs':
|
||||
return site.config.mountOptions.host + ':' + site.config.mountOptions.remoteDir + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 's3':
|
||||
return site.config.region + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'minio':
|
||||
return site.config.endpoint + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
case 'gcs':
|
||||
return site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
default:
|
||||
return regionName(site.provider, site.config.endpoint) + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
|
||||
}
|
||||
}
|
||||
|
||||
function eventlogDetails(eventLog, app = null, appIdContext = '') {
|
||||
const data = eventLog.data;
|
||||
const errorMessage = data.errorMessage;
|
||||
@@ -693,6 +660,35 @@ function parseFullBackupPath(fullPath) {
|
||||
return { prefix, remotePath };
|
||||
}
|
||||
|
||||
function base64urlEncode(buffer) {
|
||||
return btoa(String.fromCharCode(...buffer))
|
||||
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
function generateCodeVerifier() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return base64urlEncode(array);
|
||||
}
|
||||
|
||||
async function computeCodeChallenge(verifier) {
|
||||
const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
|
||||
return base64urlEncode(new Uint8Array(hash));
|
||||
}
|
||||
|
||||
async function startAuthFlow(clientId, apiOrigin) {
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = await computeCodeChallenge(codeVerifier);
|
||||
|
||||
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
|
||||
sessionStorage.setItem('pkce_client_id', clientId);
|
||||
sessionStorage.setItem('pkce_api_origin', apiOrigin || '');
|
||||
|
||||
const redirectUri = window.location.origin + '/authcallback.html';
|
||||
const base = apiOrigin || '';
|
||||
return `${base}/openid/auth?client_id=${clientId}&scope=openid email profile&response_type=code&redirect_uri=${redirectUri}&code_challenge=${codeChallenge}&code_challenge_method=S256`;
|
||||
}
|
||||
|
||||
// named exports
|
||||
export {
|
||||
renderSafeMarkdown,
|
||||
@@ -712,8 +708,8 @@ export {
|
||||
getColor,
|
||||
prettySchedule,
|
||||
parseSchedule,
|
||||
prettySiteLocation,
|
||||
parseFullBackupPath
|
||||
parseFullBackupPath,
|
||||
startAuthFlow
|
||||
};
|
||||
|
||||
// default export
|
||||
@@ -735,6 +731,6 @@ export default {
|
||||
getColor,
|
||||
prettySchedule,
|
||||
parseSchedule,
|
||||
prettySiteLocation,
|
||||
parseFullBackupPath
|
||||
parseFullBackupPath,
|
||||
startAuthFlow
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, onMounted } from 'vue';
|
||||
import { Button, Checkbox, FormGroup, TextInput, PasswordInput, EmailInput } from '@cloudron/pankow';
|
||||
import ProvisionModel from '../models/ProvisionModel.js';
|
||||
import { redirectIfNeeded } from '../utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from '../utils.js';
|
||||
|
||||
const provisionModel = ProvisionModel.create();
|
||||
|
||||
@@ -59,7 +59,7 @@ async function onOwnerSubmit() {
|
||||
// set token to autologin on first oidc flow
|
||||
localStorage.cloudronFirstTimeToken = result;
|
||||
|
||||
window.location.href = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
|
||||
window.location.href = await startAuthFlow('cid-webadmin', '');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
@@ -26,11 +26,9 @@ import Storage from '../components/app/Storage.vue';
|
||||
import Uninstall from '../components/app/Uninstall.vue';
|
||||
import Updates from '../components/app/Updates.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import { API_ORIGIN, APP_TYPES, ISTATES, RSTATES, HSTATES } from '../constants.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const installationStateLabel = AppsModel.installationStateLabel;
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
@@ -50,6 +48,8 @@ const sftpInfoDialog = useTemplateRef('sftpInfoDialog');
|
||||
|
||||
let refreshTimer = null;
|
||||
async function refresh() {
|
||||
clearTimeout(refreshTimer);
|
||||
|
||||
const [error, result] = await appsModel.get(id.value);
|
||||
if (error) {
|
||||
if (error.status === 403) return window.location.hash = '/';
|
||||
@@ -168,7 +168,7 @@ async function onStopAppTask() {
|
||||
|
||||
busyStopTask.value = true;
|
||||
|
||||
const [error] = await tasksModel.stop(app.value.taskId);
|
||||
const [error] = await appsModel.stopAppTask(app.value.id, app.value.taskId);
|
||||
if (error) console.error(error);
|
||||
|
||||
busyStopTask.value = false;
|
||||
@@ -342,19 +342,19 @@ onBeforeUnmount(() => {
|
||||
<Transition name="slide-fade" mode="out-in">
|
||||
<Info v-if="currentView === 'info'" :app="app"/>
|
||||
<Display v-else-if="currentView === 'display'" :app="app"/>
|
||||
<Location v-else-if="currentView === 'location'" :app="app"/>
|
||||
<Location v-else-if="currentView === 'location'" :app="app" :refresh-app="refresh"/>
|
||||
<Proxy v-else-if="currentView === 'proxy'" :app="app"/>
|
||||
<Access v-else-if="currentView === 'access'" :app="app"/>
|
||||
<Resources v-else-if="currentView === 'resources'" :app="app"/>
|
||||
<Services v-else-if="currentView === 'services'" :app="app"/>
|
||||
<Storage v-else-if="currentView === 'storage'" :app="app"/>
|
||||
<Resources v-else-if="currentView === 'resources'" :app="app" :refresh-app="refresh"/>
|
||||
<Services v-else-if="currentView === 'services'" :app="app" :refresh-app="refresh"/>
|
||||
<Storage v-else-if="currentView === 'storage'" :app="app" :refresh-app="refresh"/>
|
||||
<Graphs v-else-if="currentView === 'graphs'" :app="app"/>
|
||||
<Security v-else-if="currentView === 'security'" :app="app"/>
|
||||
<Email v-else-if="currentView === 'email'" :app="app"/>
|
||||
<Email v-else-if="currentView === 'email'" :app="app" :refresh-app="refresh"/>
|
||||
<Cron v-else-if="currentView === 'cron'" :app="app"/>
|
||||
<Updates v-else-if="currentView === 'updates'" :app="app" :refresh-app="refresh"/>
|
||||
<Backups v-else-if="currentView === 'backups'" :app="app"/>
|
||||
<Repair v-else-if="currentView === 'repair'" :app="app"/>
|
||||
<Repair v-else-if="currentView === 'repair'" :app="app" :refresh-app="refresh"/>
|
||||
<Eventlog v-else-if="currentView === 'eventlog'" :app="app"/>
|
||||
<Uninstall v-else-if="currentView === 'uninstall'" :app="app"/>
|
||||
</Transition>
|
||||
|
||||
@@ -171,7 +171,8 @@ function createAppLinkActionMenu(app) {
|
||||
const filteredApps = computed(() => {
|
||||
return apps.value.filter(a => {
|
||||
if (a.type === APP_TYPES.LINK) {
|
||||
return a.upstreamUri.includes(filter.value);
|
||||
return a.upstreamUri.includes(filter.value)
|
||||
|| (a.label ? a.label.toLowerCase().indexOf(filter.value.toLocaleLowerCase()) !== -1 : false);
|
||||
} else { // app or proxy
|
||||
return a.fqdn.includes(filter.value)
|
||||
|| a.secondaryDomains.some(sd => sd.fqdn.includes(filter.value))
|
||||
@@ -452,8 +453,8 @@ onDeactivated(() => {
|
||||
<div v-if="apps.length === 0" class="empty-placeholder">
|
||||
<!-- admins or not-->
|
||||
<div v-if="profile.isAtLeastAdmin">
|
||||
<h4>{{ $t('apps.noApps.title') }}</h4>
|
||||
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })"></h5>
|
||||
<h4 style="font-weight: 400">{{ $t('apps.noApps.title') }}</h4>
|
||||
<h5 v-html="$t('apps.noApps.description', { appStoreLink: '#/appstore' })" style="font-weight: 400"></h5>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h4>{{ $t('apps.noAccess.title') }}</h4>
|
||||
|
||||
@@ -243,8 +243,8 @@ async function onHashChange() {
|
||||
const version = params.get('version') || 'latest';
|
||||
|
||||
const [error, result] = await appstoreModel.get(appId, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
if (error || !result.manifest) {
|
||||
if (error) console.error(error);
|
||||
return inputDialog.value.info({
|
||||
title: t('appstore.appNotFoundDialog.title'),
|
||||
message: t('appstore.appNotFoundDialog.description', { appId, version }),
|
||||
|
||||
@@ -20,7 +20,7 @@ import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
import { prettySchedule, prettySiteLocation } from '../utils.js';
|
||||
import { prettySchedule } from '../utils.js';
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -100,7 +100,10 @@ async function onRemoveSite(site) {
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await backupSitesModels.del(site.id);
|
||||
if (error) console.error(error);
|
||||
if (error) {
|
||||
window.pankow.notify({ text: error.body?.message || 'Failed to delete backup site', type: 'danger' });
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
await refresh();
|
||||
|
||||
@@ -318,7 +321,7 @@ onMounted(async () => {
|
||||
|
||||
<div>
|
||||
<b>Storage:</b> {{ site.provider }} ({{ site.format }})
|
||||
<span>at {{ prettySiteLocation(site) }}</span>
|
||||
<span>at {{ site.locationLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -348,15 +351,13 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 10px;" class="text-danger" v-if="site.status.message" v-html="site.status.message"></div>
|
||||
<div v-if="site.task && site.task.running">
|
||||
<div style="margin-top: 10px; display: flex; align-items: center; gap: 10px; overflow: hidden;">
|
||||
<div style="flex-grow: 1; overflow: hidden;">
|
||||
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
|
||||
<div style="display: block; margin-top: 3px; max-width: 100%; text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
|
||||
</div>
|
||||
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
|
||||
<div v-if="site.task && site.task.running" style="margin-top: 10px; display: grid; grid-template-columns: 1fr auto auto; column-gap: 10px; align-items: center;">
|
||||
<div style="overflow: hidden;">
|
||||
<ProgressBar :busy="true" :show-label="false" :value="site.task.percent" :mode="site.task.percent <= 0 ? 'indeterminate' : null" />
|
||||
</div>
|
||||
<Button plain tool :href="`/logs.html?taskId=${site.task.id}`" target="_blank">Logs</Button>
|
||||
<Button danger plain tool icon="fa-solid fa-xmark" @click="onCancelTask(site.task.id)"></Button>
|
||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden;">{{ site.task.percent }}% {{ site.task.message }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { prettyDecimalSize, sleep } from '@cloudron/pankow/utils';
|
||||
import { prettyRelayProviderName } from '../utils.js';
|
||||
import { TextInput } from '@cloudron/pankow';
|
||||
import { TextInput, ProgressBar } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import StateLED from '../components/StateLED.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -13,6 +13,7 @@ const domainsModel = DomainsModel.create();
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
const domains = ref([]);
|
||||
const busy = ref(true);
|
||||
|
||||
const searchFilter = ref('');
|
||||
|
||||
@@ -84,7 +85,10 @@ async function refreshUsage() {
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
result.forEach(d => {
|
||||
d.loadingStatus = true;
|
||||
@@ -100,6 +104,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
domains.value = result;
|
||||
busy.value = false;
|
||||
|
||||
refreshStatus();
|
||||
refreshUsage();
|
||||
@@ -111,7 +116,7 @@ onMounted(async () => {
|
||||
<div class="content">
|
||||
<Section :title="$t('emails.domains.title')">
|
||||
<template #header-title-extra>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ domains.length === 0 ? '-' : filteredDomains.length }})</span>
|
||||
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredDomains.length }})</span>
|
||||
</template>
|
||||
|
||||
<template #header-buttons>
|
||||
@@ -119,6 +124,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<div>
|
||||
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
|
||||
<div v-if="domains.length !== 0 && filteredDomains.length === 0" class="email-placeholder">{{ $t('domains.noMatchesPlaceholder') }}</div>
|
||||
<a v-for="domain in filteredDomains" :key="domain.domain" :href="`#/email-domain/${domain.domain}`" class="email-domain">
|
||||
<div style="display: flex; align-items: center;">
|
||||
|
||||
@@ -16,6 +16,7 @@ const username = ref('');
|
||||
const password = ref('');
|
||||
const totpToken = ref('');
|
||||
const passkeyOptions = ref(null);
|
||||
const totpRequired = ref(false);
|
||||
const twoFARequired = ref(false);
|
||||
|
||||
// coming from oidc_login.html template
|
||||
@@ -24,6 +25,8 @@ const note = window.cloudron.note;
|
||||
const iconUrl = window.cloudron.iconUrl;
|
||||
const footer = window.cloudron.footer;
|
||||
const submitUrl = window.cloudron.submitUrl;
|
||||
const passkeyAuthOptionsUrl = window.cloudron.passkeyAuthOptionsUrl;
|
||||
const passkeyLoginUrl = window.cloudron.passkeyLoginUrl;
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
@@ -60,11 +63,11 @@ async function onSubmit() {
|
||||
if (res.body.twoFactorRequired) {
|
||||
twoFARequired.value = true;
|
||||
passkeyOptions.value = res.body.passkeyOptions || null;
|
||||
totpRequired.value = !!res.body.totpRequired;
|
||||
totpToken.value = '';
|
||||
|
||||
// If only passkeys are available (no TOTP), auto-trigger passkey flow
|
||||
if (passkeyOptions.value) await onUsePasskey();
|
||||
else setTimeout(() => document.getElementById('inputTotpToken')?.focus(), 0);
|
||||
else if (totpRequired.value) setTimeout(() => document.getElementById('inputTotpToken')?.focus(), 0);
|
||||
} else if (res.body.redirectTo) {
|
||||
return window.location.href = res.body.redirectTo;
|
||||
} else {
|
||||
@@ -122,6 +125,41 @@ async function onUsePasskey() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onLoginWithPasskey() {
|
||||
busy.value = true;
|
||||
passkeyError.value = false;
|
||||
internalError.value = false;
|
||||
|
||||
try {
|
||||
const optionsRes = await fetcher.post(passkeyAuthOptionsUrl, {});
|
||||
if (optionsRes.status !== 200) {
|
||||
internalError.value = true;
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const credential = await startAuthentication({ optionsJSON: optionsRes.body });
|
||||
|
||||
const res = await fetcher.post(passkeyLoginUrl, { passkeyResponse: credential });
|
||||
|
||||
if (res.status === 200 && res.body.redirectTo) {
|
||||
window.location.href = res.body.redirectTo;
|
||||
return;
|
||||
} else if (res.status === 401) {
|
||||
passkeyError.value = true;
|
||||
} else {
|
||||
internalError.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== 'NotAllowedError') {
|
||||
console.error('Passkey login failed', error);
|
||||
passkeyError.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// placed optionally in local storage by setupaccount.js
|
||||
const autoLoginToken = localStorage.cloudronFirstTimeToken;
|
||||
@@ -157,7 +195,10 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="inputPassword">{{ $t('login.password') }}</label>
|
||||
<div style="display: flex; justify-content: space-between; align-items: baseline;">
|
||||
<label for="inputPassword">{{ $t('login.password') }}</label>
|
||||
<a href="/passwordreset.html" tabindex="-1" style="font-size: 0.85em;">{{ $t('login.resetPasswordAction') }}</a>
|
||||
</div>
|
||||
<PasswordInput id="inputPassword" v-model="password" required/>
|
||||
</FormGroup>
|
||||
|
||||
@@ -167,22 +208,22 @@ onMounted(async () => {
|
||||
</fieldset>
|
||||
|
||||
<div class="actions">
|
||||
<Button id="loginSubmitButton" @click="onSubmit" :disabled="busy || (!username || !password)" :loading="busy">{{ $t('login.loginAction') }}</Button>
|
||||
<a href="/passwordreset.html">{{ $t('login.resetPasswordAction') }}</a>
|
||||
<Button id="loginSubmitButton" @click="onSubmit" :disabled="busy || (!username || !password)">{{ $t('login.loginAction') }}</Button>
|
||||
<a href="#" @click.prevent="onLoginWithPasskey" tabindex="-1">{{ $t('login.passkeyAction') }}</a>
|
||||
</div>
|
||||
<div class="error-label" v-if="passkeyError">{{ $t('login.errorPasskeyFailed') }}</div>
|
||||
</form>
|
||||
|
||||
<form @submit.prevent="onSubmit" v-if="twoFARequired" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<!-- Passkey or TOTP option -->
|
||||
<div v-if="passkeyOptions">
|
||||
<Button id="passkeyButton" @click="onUsePasskey" :disabled="busy" :loading="busy">{{ $t('login.usePasskeyAction') }}</Button>
|
||||
<Button id="passkeyButton" @click="onUsePasskey" :disabled="busy">{{ $t('login.usePasskeyAction') }}</Button>
|
||||
<div class="error-label" v-if="passkeyError">{{ $t('login.errorPasskeyFailed') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="totpRequired">
|
||||
<FormGroup :has-error="totpError">
|
||||
<label for="inputTotpToken">{{ $t('login.2faToken') }}</label>
|
||||
<TextInput id="inputTotpToken" v-model="totpToken"/>
|
||||
@@ -191,7 +232,7 @@ onMounted(async () => {
|
||||
<div class="error-label" v-if="totpError">{{ $t('login.errorIncorrect2FAToken') }}</div>
|
||||
|
||||
<div class="actions">
|
||||
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken" :loading="busy">{{ $t('login.loginAction') }}</Button>
|
||||
<Button id="totpTokenSubmitButton" @click="onSubmit" :disabled="busy || !totpToken">{{ $t('login.loginAction') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { ref, onMounted, inject, useTemplateRef } from 'vue';
|
||||
import { Button, ButtonGroup } from '@cloudron/pankow';
|
||||
import { Button, ButtonGroup, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyDate } from '@cloudron/pankow/utils';
|
||||
import Section from '../components/Section.vue';
|
||||
import NotificationSettingsDialog from '../components/NotificationSettingsDialog.vue';
|
||||
@@ -86,9 +86,12 @@ function onSetShowAll(value) {
|
||||
}
|
||||
|
||||
async function onLoadMore() {
|
||||
busy.value = true;
|
||||
if (!hasMore.value || busy.value) return;
|
||||
|
||||
const [error, result] = await notificationsModel.list(showAll.value ? null : false, page++);
|
||||
busy.value = true;
|
||||
page++;
|
||||
|
||||
const [error, result] = await notificationsModel.list(showAll.value ? null : false, page);
|
||||
if (error) return console.error(error);
|
||||
|
||||
hasMore.value = result.length >= PAGE_SIZE;
|
||||
@@ -97,6 +100,11 @@ async function onLoadMore() {
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onScroll(event) {
|
||||
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await onLoadMore();
|
||||
}
|
||||
|
||||
const notificationListContainer = useTemplateRef('notificationListContainer');
|
||||
const notificationSettingsDialog = useTemplateRef('notificationSettingsDialog');
|
||||
function onOpenSettings() {
|
||||
notificationSettingsDialog.value.open();
|
||||
@@ -111,10 +119,10 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="content">
|
||||
<div class="content" style="height: 100%; overflow: hidden; display: flex; flex-direction: column;">
|
||||
<NotificationSettingsDialog ref="notificationSettingsDialog"/>
|
||||
|
||||
<Section :title="$t('notifications.title')">
|
||||
<Section :title="$t('notifications.title')" style="overflow: hidden; display: flex; flex-direction: column;">
|
||||
<template #filter-bar>
|
||||
<ButtonGroup>
|
||||
<Button :outline="showAll ? true : undefined" @click="onSetShowAll(false)">{{ $t('notifications.showUnread') }}</Button>
|
||||
@@ -126,7 +134,11 @@ onMounted(async () => {
|
||||
<Button @click="onOpenSettings" icon="fa fa-screwdriver-wrench" tool/>
|
||||
</template>
|
||||
|
||||
<div class="notification-list">
|
||||
<ProgressBar v-if="busy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
|
||||
|
||||
<div v-if="!busy && notifications.length === 0" class="empty-placeholder">{{ $t('notifications.allCaughtUp') }}</div>
|
||||
|
||||
<div ref="notificationListContainer" class="notification-list" @scroll="onScroll">
|
||||
<div v-for="notification in notifications" :key="notification.id" class="notification-item" :class="{ new: !notification.acknowledged, active: notification.active }">
|
||||
<div class="notification-item-title" @click="onToggleActive(notification)">
|
||||
<div>
|
||||
@@ -145,26 +157,28 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; padding-bottom: 20px;">
|
||||
<Button @click="onLoadMore()" plain secondary v-if="hasMore" :disabled="busy" :loading="busy">{{ $t('main.action.loadMore') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.section-body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-direction: column;
|
||||
padding-bottom: 10px;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.notification-item.new {
|
||||
@@ -181,6 +195,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.notification-item-date {
|
||||
@@ -191,7 +206,6 @@ onMounted(async () => {
|
||||
|
||||
.notification-item-message {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
padding-bottom: 10px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
@@ -210,4 +224,9 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
text-align: center;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
61
dashboard/src/views/OidcDeviceConfirmView.vue
Normal file
61
dashboard/src/views/OidcDeviceConfirmView.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup>
|
||||
|
||||
// keep this to load pankow Button css
|
||||
import { Button } from '@cloudron/pankow';
|
||||
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
|
||||
// coming from oidc_device_confirm.html server-side rendered
|
||||
const clientName = window.cloudron.clientName;
|
||||
const userCode = window.cloudron.userCode;
|
||||
const form = window.cloudron.form;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout>
|
||||
<div>
|
||||
<h2>Authorize {{ clientName }}</h2>
|
||||
<p>Verify the code below</p>
|
||||
<div class="user-code">{{ userCode }}</div>
|
||||
<p class="code-hint">If you did not initiate this action or the code does not match, please close this window or cancel.</p>
|
||||
|
||||
<!-- injected form for submission from oidcserver.js -->
|
||||
<div v-html="form"></div>
|
||||
|
||||
<button class="pankow-button" type="submit" form="op.deviceConfirmForm">Continue</button>
|
||||
<button type="submit" form="op.deviceConfirmForm" value="yes" name="abort" class="cancel-button">Cancel</button>
|
||||
</div>
|
||||
</PublicPageLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
p {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.user-code {
|
||||
font-size: 26px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.code-hint {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
margin-left: 15px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--pankow-color-dark);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
color: var(--pankow-color-primary-hover);
|
||||
}
|
||||
|
||||
</style>
|
||||
26
dashboard/src/views/OidcDeviceInputView.vue
Normal file
26
dashboard/src/views/OidcDeviceInputView.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup>
|
||||
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
|
||||
// coming from oidc_device_input.html server-side rendered
|
||||
const message = window.cloudron.message;
|
||||
const form = window.cloudron.form;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout>
|
||||
<div>
|
||||
<h2>Authorization canceled</h2>
|
||||
|
||||
<!-- in theory one can enter the code manually there, but we don't support this right now
|
||||
<div v-html="message"></div>
|
||||
<div v-html="form"></div>
|
||||
<button type="submit" form="op.deviceInputForm">Continue</button>
|
||||
-->
|
||||
</div>
|
||||
</PublicPageLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||
17
dashboard/src/views/OidcDeviceSuccessView.vue
Normal file
17
dashboard/src/views/OidcDeviceSuccessView.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup>
|
||||
|
||||
import PublicPageLayout from '../components/PublicPageLayout.vue';
|
||||
|
||||
// coming from oidc_device_success.html server-side rendered (optional)
|
||||
const cloudronName = window.cloudron.name;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout :cloudron-name="cloudronName">
|
||||
<div style="max-width: 300px;">
|
||||
<h2>Success</h2>
|
||||
<p>Your device has been authorized. You can close this window.</p>
|
||||
</div>
|
||||
</PublicPageLayout>
|
||||
</template>
|
||||
@@ -35,7 +35,7 @@ async function onPasswordReset() {
|
||||
if (res.status === 409) {
|
||||
error.value.generic = res.body.message;
|
||||
} else if (res.status === 202) {
|
||||
mode.value = MODE.NEW_PASSWORD_DONE;
|
||||
mode.value = MODE.RESET_PASSWORD_DONE;
|
||||
}
|
||||
} catch (error) {
|
||||
error.value.generic = error;
|
||||
@@ -46,6 +46,8 @@ async function onPasswordReset() {
|
||||
}
|
||||
|
||||
async function onNewPassword() {
|
||||
if (newPassword.value !== newPasswordRepeat.value) return;
|
||||
|
||||
busy.value = true;
|
||||
error.value = {};
|
||||
|
||||
|
||||
@@ -100,32 +100,23 @@ async function onRevokeAllWebAndCliTokens() {
|
||||
await profileModel.logout();
|
||||
}
|
||||
|
||||
const userPasskey = ref(null);
|
||||
const enableTwoFADialog = useTemplateRef('enableTwoFADialog');
|
||||
const has2FA = computed(() => profile.value.twoFactorAuthenticationEnabled || !!userPasskey.value);
|
||||
const has2FA = computed(() => profile.value.totpEnabled || profile.value.hasPasskey);
|
||||
|
||||
async function loadPasskey() {
|
||||
const [error, result] = await profileModel.getPasskey();
|
||||
if (error) return console.error('Failed to load passkey', error);
|
||||
userPasskey.value = result;
|
||||
}
|
||||
|
||||
async function onOpenTwoFASetupDialog() {
|
||||
enableTwoFADialog.value.open();
|
||||
async function onOpenTwoFASetupDialog(method) {
|
||||
enableTwoFADialog.value.open(method);
|
||||
}
|
||||
|
||||
async function onEnableTwoFASuccess() {
|
||||
await refreshProfile();
|
||||
await loadPasskey();
|
||||
}
|
||||
|
||||
async function onTwoFADisable() {
|
||||
disableTwoFADialog.value.open(userPasskey.value ? 'passkey' : 'totp');
|
||||
async function onTwoFADisable(method) {
|
||||
disableTwoFADialog.value.open(method);
|
||||
}
|
||||
|
||||
async function onTwoFADisableSuccess() {
|
||||
await refreshProfile();
|
||||
await loadPasskey();
|
||||
}
|
||||
|
||||
// Init
|
||||
@@ -161,10 +152,8 @@ onMounted(async () => {
|
||||
webadminTokens.value = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_WEBADMIN || c.clientId === TOKEN_TYPES.ID_DEVELOPMENT || c.clientId === 'dashboard' || c.clientId === 'development'; });
|
||||
cliTokens.value = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_CLI; });
|
||||
|
||||
await loadPasskey();
|
||||
|
||||
// check if we should show the 2fa setup
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled && !userPasskey.value) {
|
||||
if (config.value.mandatory2FA && !profile.value.totpEnabled && !profile.value.hasPasskey) {
|
||||
onOpenTwoFASetupDialog();
|
||||
}
|
||||
});
|
||||
@@ -230,13 +219,25 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem v-if="!profile.source || !config.external2FA">
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.twoFactorAuth.title') }}</label>
|
||||
<div v-if="!has2FA">{{ $t('profile.twoFactorAuth.disabled') }}</div>
|
||||
<div v-else-if="profile.twoFactorAuthenticationEnabled">{{ $t('profile.twoFactorAuth.totpEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
|
||||
<div v-else-if="userPasskey">{{ $t('profile.twoFactorAuth.passkeyEnabled') }} <i class="fa-solid fa-check text-success"></i></div>
|
||||
<label>{{ $t('profile.twoFactorAuth.totpTitle') }}</label>
|
||||
<div v-if="profile.totpEnabled">{{ $t('profile.twoFactorAuth.totpEnabled') }}</div>
|
||||
<div v-else-if="profile.id">{{ $t('profile.notSet') }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center">
|
||||
<Button tool plain @click="has2FA ? onTwoFADisable() : onOpenTwoFASetupDialog()">{{ $t(has2FA ? 'profile.disable2FAAction' : 'profile.enable2FAAction') }}</Button>
|
||||
<div v-if="profile.id" style="display: flex; align-items: center">
|
||||
<Button tool plain v-if="profile.totpEnabled" @click="onTwoFADisable('totp')">{{ $t('main.action.disable') }}</Button>
|
||||
<Button tool plain v-else @click="onOpenTwoFASetupDialog('totp')">{{ $t('main.action.setup') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem v-if="!profile.source || !config.external2FA">
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.twoFactorAuth.passkeyTitle') }}</label>
|
||||
<div v-if="profile.hasPasskey">{{ $t('profile.twoFactorAuth.passkeyEnabled') }}</div>
|
||||
<div v-else-if="profile.id">{{ $t('profile.notSet') }}</div>
|
||||
</FormGroup>
|
||||
<div v-if="profile.id" style="display: flex; align-items: center">
|
||||
<Button tool plain v-if="profile.hasPasskey" @click="onTwoFADisable('passkey')">{{ $t('main.action.disable') }}</Button>
|
||||
<Button tool plain v-else @click="onOpenTwoFASetupDialog('passkey')">{{ $t('main.action.setup') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
|
||||
@@ -179,7 +179,7 @@ async function onSubmit() {
|
||||
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
config.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs' || provider.value === 'disk') {
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
config.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
|
||||
@@ -18,6 +18,15 @@ import AppsModel from '../models/AppsModel.js';
|
||||
const appsModel = AppsModel.create();
|
||||
const servicesModel = ServicesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
const BOX_SERVICE = Object.freeze({
|
||||
id: 'box',
|
||||
name: 'cloudron',
|
||||
status: 'active',
|
||||
memoryUsed: 0,
|
||||
memoryPercent: 0,
|
||||
memoryLimit: 0,
|
||||
config: {}
|
||||
});
|
||||
|
||||
const columns = {
|
||||
status: {},
|
||||
@@ -63,16 +72,7 @@ function createActionMenu(id) {
|
||||
}];
|
||||
}
|
||||
|
||||
const services = reactive({
|
||||
box: {
|
||||
name: 'cloudron',
|
||||
status: 'active',
|
||||
memoryUsed: 0,
|
||||
memoryUsage: '',
|
||||
memoryLimit: 0,
|
||||
config: {}
|
||||
}
|
||||
});
|
||||
const services = reactive({});
|
||||
|
||||
const servicesArray = computed(() => {
|
||||
return Object.keys(services).map(s => {
|
||||
@@ -101,24 +101,34 @@ async function refresh(id) {
|
||||
services[id].memoryPercent = result.memoryPercent || 0;
|
||||
services[id].defaultMemoryLimit = result.defaultMemoryLimit;
|
||||
|
||||
// we will poll until active
|
||||
if (result.status !== 'active' && !result.config.recoveryMode) servicesTimers[id] = setTimeout(refresh.bind(null, id), 3000);
|
||||
// we will poll until active (idle services are intentionally stopped, no need to poll)
|
||||
if (result.status !== 'active' && result.status !== 'idle' && !result.config.recoveryMode) servicesTimers[id] = setTimeout(refresh.bind(null, id), 3000);
|
||||
}
|
||||
|
||||
const refreshBusy = ref(false);
|
||||
const initialLoadBusy = ref(true);
|
||||
async function refreshAll() {
|
||||
refreshBusy.value = true;
|
||||
|
||||
let [error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
if (error) {
|
||||
refreshBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
apps = result;
|
||||
|
||||
[error, result] = await servicesModel.list();
|
||||
if (error) return console.error(error);
|
||||
if (error) {
|
||||
refreshBusy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
const allServices = result;
|
||||
if (!services[BOX_SERVICE.id]) services[BOX_SERVICE.id] = { ...BOX_SERVICE, config: {} };
|
||||
|
||||
// init with all services
|
||||
for (const s of result.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
for (const s of allServices.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!services[s.id]) services[s.id] = { id: s.id, name: s.name, config: {}, status: '' };
|
||||
|
||||
if (s.id.indexOf('redis') === 0) {
|
||||
@@ -128,7 +138,8 @@ async function refreshAll() {
|
||||
}
|
||||
}
|
||||
|
||||
await each(result.map(s => s.id), refresh);
|
||||
await each(allServices.map(s => s.id), refresh);
|
||||
initialLoadBusy.value = false;
|
||||
refreshBusy.value = false;
|
||||
}
|
||||
|
||||
@@ -196,8 +207,10 @@ async function onEditSubmit() {
|
||||
function state(service) {
|
||||
switch (service.status) {
|
||||
case 'active': return 'success';
|
||||
case 'idle': return 'idle';
|
||||
case 'disabled': return '';
|
||||
case 'stopped': return 'danger';
|
||||
case 'error': return 'danger';
|
||||
case 'starting': return service.config.recoveryMode ? '' : 'warning';
|
||||
default: return 'danger';
|
||||
}
|
||||
@@ -206,8 +219,10 @@ function state(service) {
|
||||
function stateTooltip(service) {
|
||||
switch (service.status) {
|
||||
case 'active': return 'Active';
|
||||
case 'idle': return 'Idle (starts on demand)';
|
||||
case 'disabled': return 'Disabled';
|
||||
case 'stopped': return 'Stopped';
|
||||
case 'error': return 'Error';
|
||||
case 'starting': return service.config.recoveryMode ? 'Recovery mode' : 'Starting';
|
||||
default: return service.status;
|
||||
}
|
||||
@@ -263,7 +278,9 @@ onUnmounted(() => {
|
||||
<div>{{ $t('services.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="servicesArray">
|
||||
<ProgressBar v-if="initialLoadBusy" mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
|
||||
|
||||
<TableView v-if="!initialLoadBusy" :columns="columns" :model="servicesArray">
|
||||
<template #status="{ item:service }">
|
||||
<StateLED :busy="!service.status" :state="state(service)" :title="stateTooltip(service)"/>
|
||||
</template>
|
||||
|
||||
@@ -71,7 +71,7 @@ function createUserActionMenu(user) {
|
||||
}, {
|
||||
icon: 'fa-solid fa-qrcode',
|
||||
label: t('users.passwordResetDialog.reset2FAAction'),
|
||||
visible: user.twoFactorAuthenticationEnabled && !(user.source && external2FA),
|
||||
visible: user.totpEnabled && !(user.source && external2FA),
|
||||
disabled: !canEdit(user),
|
||||
action: on2FAReset.bind(null, user),
|
||||
}, {
|
||||
@@ -212,10 +212,10 @@ async function on2FAReset(user) {
|
||||
|
||||
if (!yes) return;
|
||||
|
||||
const [error] = await usersModel.disableTwoFactorAuthentication(user.id);
|
||||
const [error] = await usersModel.disableTotp(user.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
user.twoFactorAuthenticationEnabled = false;
|
||||
user.totpEnabled = false;
|
||||
}
|
||||
|
||||
function onEditOrAddUser(user = null) {
|
||||
|
||||
@@ -267,6 +267,7 @@ onMounted(async () =>{
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'filesystem' || volumeDialogData.mountType === 'mountpoint'">
|
||||
<label for="volumeHostPath">{{ $t('volumes.localDirectory') }}</label>
|
||||
<TextInput id="volumeHostPath" v-model="volumeDialogData.hostPath" :placeholder="volumeDialogData.mountType === 'filesystem' ? '/srv/shared' : '/mnt/data'" required/>
|
||||
<small class="warning-label" v-if="volumeDialogData.mountType === 'mountpoint'" v-html="$t('volumes.mountPointDescription', { docsLink: 'https://docs.cloudron.io/volumes/#user-managed-mount-point' })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="volumeDialogData.mountType === 'ext4' || volumeDialogData.mountType === 'xfs'">
|
||||
@@ -327,7 +328,7 @@ onMounted(async () =>{
|
||||
<TableView :columns="columns" :model="volumes" :busy="busy" :fixed-layout="true" :placeholder="$t('volumes.emptyPlaceholder')">
|
||||
<template #target="{ item:volume }">
|
||||
<span v-if="volume.mountType === 'mountpoint' || volume.mountType === 'filesystem'">{{ volume.hostPath }}</span>
|
||||
<span v-else-if="volume.mountType === 'ext4' || volume.mountType === 'xfs' || volume.mountType === 'disk'">{{ volume.mountOptions.diskPath }}</span>
|
||||
<span v-else-if="volume.mountType === 'ext4' || volume.mountType === 'xfs'">{{ volume.mountOptions.diskPath }}</span>
|
||||
<span v-else-if="volume.mountType === 'sshfs'">{{ volume.mountOptions.host + '/' + volume.mountOptions.remoteDir }}</span>
|
||||
<!-- cifs/nfs -->
|
||||
<span v-else>{{ volume.mountOptions.host + volume.mountOptions.remoteDir }}</span>
|
||||
|
||||
@@ -14,6 +14,9 @@ export default defineConfig({
|
||||
resolve('oidc_error.html'),
|
||||
resolve('oidc_interaction_confirm.html'),
|
||||
resolve('oidc_interaction_abort.html'),
|
||||
resolve('oidc_device_input.html'),
|
||||
resolve('oidc_device_confirm.html'),
|
||||
resolve('oidc_device_success.html'),
|
||||
resolve('logs.html'),
|
||||
resolve('passwordreset.html'),
|
||||
resolve('restore.html'),
|
||||
@@ -43,6 +46,9 @@ export default defineConfig({
|
||||
oidc_error: resolve('oidc_error.html'),
|
||||
oidc_interaction_confirm: resolve('oidc_interaction_confirm.html'),
|
||||
oidc_interaction_abort: resolve('oidc_interaction_abort.html'),
|
||||
oidc_device_input: resolve('oidc_device_input.html'),
|
||||
oidc_device_confirm: resolve('oidc_device_confirm.html'),
|
||||
oidc_device_success: resolve('oidc_device_success.html'),
|
||||
logs: resolve('logs.html'),
|
||||
notfound: resolve('notfound.html'),
|
||||
passwordreset: resolve('passwordreset.html'),
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
var url = require('node:url');
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
var dbName = url.parse(process.env.DATABASE_URL).path.substr(1); // remove slash
|
||||
var dbName = new URL(process.env.DATABASE_URL).pathname.slice(1); // remove slash
|
||||
|
||||
// by default, mysql collates case insensitively. 'utf8_general_cs' is not available
|
||||
db.runSql('ALTER DATABASE ' + dbName + ' DEFAULT CHARACTER SET=utf8 DEFAULT COLLATE utf8_bin', callback);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
var safe = require('safetydance');
|
||||
var safe = require('@cloudron/safetydance').default;
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
var tz = safe.fs.readFileSync('/etc/timezone', 'utf8');
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
var url = require('node:url');
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
var dbName = url.parse(process.env.DATABASE_URL).path.substr(1); // remove slash
|
||||
var dbName = new URL(process.env.DATABASE_URL).pathname.slice(1); // remove slash
|
||||
|
||||
// by default, mysql collates case insensitively. 'utf8_general_cs' is not available
|
||||
db.runSql('ALTER DATABASE ' + dbName + ' DEFAULT CHARACTER SET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci', callback);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
var async = require('async'),
|
||||
safe = require('safetydance');
|
||||
safe = require('@cloudron/safetydance').default;
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
// first check precondtion of domain entry in settings
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
var async = require('async'),
|
||||
safe = require('safetydance'),
|
||||
safe = require('@cloudron/safetydance').default,
|
||||
tld = require('tldjs');
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
|
||||
@@ -5,7 +5,7 @@ var async = require('async'),
|
||||
fs = require('node:fs'),
|
||||
os = require('node:os'),
|
||||
path = require('node:path'),
|
||||
safe = require('safetydance'),
|
||||
safe = require('@cloudron/safetydance').default,
|
||||
tldjs = require('tldjs');
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async'),
|
||||
safe = require('safetydance');
|
||||
safe = require('@cloudron/safetydance').default;
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
db.runSql('ALTER TABLE domains ADD COLUMN wellKnownJson TEXT', function (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const async = require('async'),
|
||||
fs = require('node:fs'),
|
||||
safe = require('safetydance');
|
||||
safe = require('@cloudron/safetydance').default;
|
||||
|
||||
const BOX_DATA_DIR = '/home/yellowtent/boxdata';
|
||||
const PLATFORM_DATA_DIR = '/home/yellowtent/platformdata';
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
const async = require('async'),
|
||||
fs = require('node:fs'),
|
||||
safe = require('safetydance');
|
||||
safe = require('@cloudron/safetydance').default;
|
||||
|
||||
const BOX_DATA_DIR = '/home/yellowtent/boxdata';
|
||||
const PLATFORM_DATA_DIR = '/home/yellowtent/platformdata';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
const async = require('async'),
|
||||
safe = require('safetydance');
|
||||
safe = require('@cloudron/safetydance').default;
|
||||
|
||||
const CERTS_DIR = '/home/yellowtent/boxdata/certs',
|
||||
PLATFORM_CERTS_DIR = '/home/yellowtent/platformdata/nginx/cert';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user