Compare commits

...

469 Commits

Author SHA1 Message Date
15c099056f Implement PowerDNS provider 2026-04-14 17:19:08 +00:00
8492cc3fa6 Plan implementation of PowerDNS 2026-04-14 19:06:07 +02:00
Johannes Zellner
a4ea80cf5e Use the full backup paths for sshfs remote copy
Fixes #889
2026-04-14 13:19:45 +02:00
Johannes Zellner
feacb58cd1 Only print readable shell.spawn() error details 2026-04-14 12:43:15 +02:00
Girish Ramakrishnan
1de30c0c38 reverseproxy: X-Content-Type-Options is worth keeping
looks like this has no modern replacement
2026-04-10 16:34:53 +02:00
Girish Ramakrishnan
4c30054a2d nginx: remove the various X- headers
these are all deprecated https://datatracker.ietf.org/doc/html/rfc6648
2026-04-10 16:08:20 +02:00
Girish Ramakrishnan
0b9e06c28d remove obsolete X-XSS-Protection
https://http.dev/x-xss-protection
2026-04-10 16:06:10 +02:00
Johannes Zellner
37e4a99ba6 Update dependencies 2026-04-09 16:37:53 +02:00
Girish Ramakrishnan
7078eb7482 use constants.DOCKER_IPv4_GATEWAY 2026-04-09 15:29:48 +02:00
Girish Ramakrishnan
c2ec97d641 mail: listen on the bridge IP
when requiresValidCertificate is set, we ended up injecting mutliple
IP addresses for my.domain.com - 172.18.0.1 (bridge) and the mail container IP.

Since the mail server is not running on the bridge, email may or may not be
sent depending on which IP is picked up by the app.

The solution is to make the mail container listen on the bridge as well.

The other solution might have been to introduce a new subdomain for mail container
and ensuring it is different from the dashboard subdomain. That way we can route
the requests to different IPs.
2026-04-09 15:25:19 +02:00
Girish Ramakrishnan
2a2a5ffb66 filesystem: remove shell usage
recent version of node throws this error:

(node:210013) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
2026-04-08 17:29:56 +02:00
Girish Ramakrishnan
b84ef57d58 appstore: language counts 2026-04-08 15:00:56 +02:00
Girish Ramakrishnan
14b066d3cd rename mountpoint to 'User-managed Mount Point'
this makes it clear that the user has to manage this
2026-04-08 13:30:52 +02:00
Johannes Zellner
2b5e167b07 Only update pankow 2026-04-07 18:01:15 +02:00
Johannes Zellner
c9547cbdb8 Improve app configure resource form states 2026-04-07 15:26:58 +02:00
Johannes Zellner
89a76148b4 Fix vue type casting warning 2026-04-07 14:53:44 +02:00
Girish Ramakrishnan
81fd472bb3 Fix typo crash 2026-04-07 13:21:48 +02:00
Girish Ramakrishnan
4ba9c63eb4 docker: attempt container start a few times
Docker Error: (HTTP code 500) server error - failed to set up container networking: driver failed programming external connectivity on endpoint a877975d-38be-4088-bc92-e0d7a486a818 (2e5adaa635a95bd65ca0f290712065d444528e3420c49f2f88323b40c62caaa5): failed to bind host port for 0.0.0.0:40014:172.18.16.130:40014/tcp: address already in use

This happens during app updates. Can only be two reasons:

- some race in docker not freeing up ports (unlikely)
- ephemeral port got reallocated between destroy and create as part of app update

A future commit will reserve net.ipv4.ip_local_reserved_ports as well

Similar fix as b08e3a5128
2026-04-07 13:04:56 +02:00
Girish Ramakrishnan
9e20c5a3e3 logs: escape and unescape new lines 2026-04-07 12:54:51 +02:00
Girish Ramakrishnan
20e0774df2 impersonate: just generate a random password
this way we don't let user set some insecure one. and this two step
passowrd generate is quite confusing (generate button becomes copy)
2026-04-07 12:18:16 +02:00
Girish Ramakrishnan
603244aa6a removed double progressbars 2026-04-07 11:53:54 +02:00
Girish Ramakrishnan
1cc30934c7 apppasswords: add loading state 2026-04-07 11:50:08 +02:00
Girish Ramakrishnan
053f26cd02 apppasswords: list oidc apps in the ui 2026-04-07 11:41:23 +02:00
Girish Ramakrishnan
cc82a088a9 apppassword: 16 lowercase letters in groups of 4, to make it easier to type 2026-04-07 11:01:43 +02:00
Girish Ramakrishnan
e30e384cec services: stop turn if unused by apps 2026-04-05 11:49:18 +02:00
Girish Ramakrishnan
33691a6507 schema: add missing fields 2026-04-05 11:12:06 +02:00
Girish Ramakrishnan
83917f98f5 backup sites: disable del in demo mode 2026-04-04 11:01:52 +02:00
Johannes Zellner
1fe5a61e52 Manually update tldjs rules when we create a release tarball 2026-04-03 15:24:33 +02:00
Johannes Zellner
dab9bcb9db Add local authserver to provide /verify-credentials route
This is used for apps which are using OpenID to login but still need to
be able to verify the users password or app password
2026-04-02 22:02:45 +02:00
Johannes Zellner
b2ca6206cc Fix dashboard lock file to work with node 24.13.0 2026-04-02 20:09:29 +02:00
Johannes Zellner
918c2f8587 Move to @cloudron/safetydance 2026-04-01 09:49:34 +02:00
Girish Ramakrishnan
8f851164d6 reboot: fix dashboard link 2026-04-01 09:25:10 +02:00
Johannes Zellner
d215d1998f Update docs link for tls provider 2026-03-31 13:51:08 +02:00
Girish Ramakrishnan
75e3256497 mail: update haraka to 3.1.4 2026-03-31 12:22:37 +02:00
Girish Ramakrishnan
58f5a17a83 mail: remove queue proxy
this has never worked well
2026-03-31 11:36:16 +02:00
Girish Ramakrishnan
e7c3d797be rsync: reupload files with corrupt integrity
we found sha256: null as the integrity in some of the cache files.
not sure how this happenned. for now, we just mark files with invalid
or missing sha256 for re-upload.
2026-03-31 11:31:17 +02:00
Girish Ramakrishnan
34abd5b8f5 9.1.6 changes 2026-03-30 14:40:26 +02:00
Girish Ramakrishnan
8b138d14bb backup site: remove the local disk provider
we already have ext4, xfs, mountpoint and filesystem to cover all cases

fixes #879
2026-03-30 14:37:48 +02:00
Johannes Zellner
e23abd69b5 Update frontend dependencies 2026-03-30 13:54:26 +02:00
Girish Ramakrishnan
9c16ad456d backups: set focus in the edit dialog 2026-03-30 13:52:54 +02:00
Girish Ramakrishnan
4b851afc6a location: show what DNS is being overwritten in location UI
fixes #858
2026-03-30 13:43:07 +02:00
Girish Ramakrishnan
f333148afa Update translations 2026-03-30 13:07:56 +02:00
Girish Ramakrishnan
8d0160a3e7 app configure: refresh app when a task is started 2026-03-30 10:25:26 +02:00
Girish Ramakrishnan
4a02e988c1 location: fix duplication of port bindings on submit 2026-03-30 09:47:05 +02:00
Girish Ramakrishnan
134472cd4b cloudron-support: services could be lazy-stopped 2026-03-28 14:46:00 +01:00
Girish Ramakrishnan
b40a10da7b restore: prune portBindings whose tcpPorts/udpPorts no longer exist
fixes #871
2026-03-27 18:47:52 +01:00
Girish Ramakrishnan
25f5b33d17 Remove unused secondaryDomains in update and restore code paths
fixes #814
2026-03-27 17:46:28 +01:00
Girish Ramakrishnan
f57c39bba2 repair: rebuild image 2026-03-27 16:17:41 +01:00
Girish Ramakrishnan
99b234eca8 source install: persist buildConfig so restore, import, clone work correctly 2026-03-27 16:10:43 +01:00
Girish Ramakrishnan
9c3c8cc9d1 rename promise-retry to retry 2026-03-27 11:39:38 +01:00
Girish Ramakrishnan
b08e3a5128 docker: attempt container recreate a few times
Docker Error: (HTTP code 500) server error - failed to set up container networking: driver failed programming external connectivity on endpoint a877975d-38be-4088-bc92-e0d7a486a818 (2e5adaa635a95bd65ca0f290712065d444528e3420c49f2f88323b40c62caaa5): failed to bind host port for 0.0.0.0:40014:172.18.16.130:40014/tcp: address already in use

This happens during app updates. Can only be two reasons:

- some race in docker not freeing up ports (unlikely)
- ephemeral port got reallocated between destroy and create as part of app update

A future commit will reserve net.ipv4.ip_local_reserved_ports as well
2026-03-27 10:29:26 +01:00
Girish Ramakrishnan
e48cdc85f7 notifications: subscribe owner and users to all by default 2026-03-27 09:14:18 +01:00
Johannes Zellner
a5da68a7f9 Fix overflow issue in eventlog 2026-03-26 16:15:47 +01:00
Johannes Zellner
7d594ab0d3 Also search for matches in app links labels for apps view filter 2026-03-25 22:58:55 +01:00
Johannes Zellner
9ed3d668ee Add .cursor to gitignore 2026-03-23 17:02:31 +01:00
Johannes Zellner
0da0a5e027 Show badges in header bar for expired or cancelled subs 2026-03-23 15:29:33 +01:00
Johannes Zellner
28eb0b65f4 Update dashboard dependencies 2026-03-23 11:44:12 +01:00
Johannes Zellner
1d29572ecd Wait for rest calls on app uninstall and archive 2026-03-23 11:43:25 +01:00
Johannes Zellner
07e8d242d1 fix vue warning, reactive() variables should not be const 2026-03-23 11:13:51 +01:00
Johannes Zellner
1586a286d8 Fix notifications scrolling 2026-03-23 10:42:30 +01:00
Girish Ramakrishnan
4859059eba source install: support dockerfileName and build options 2026-03-21 17:29:47 +01:00
Girish Ramakrishnan
f2949c1836 notifications: send email when manual app update is required 2026-03-21 15:59:41 +01:00
Girish Ramakrishnan
cd6acfb91d notifications: send email when manual platform update is required 2026-03-21 15:38:12 +01:00
Johannes Zellner
2d5dc9a6aa Fix wrong disabled state for devices config of apps 2026-03-21 08:54:42 +01:00
Girish Ramakrishnan
87e7da2aff community: auto focus to text input 2026-03-20 17:37:53 +01:00
Johannes Zellner
461eb38d88 Add comment why unused import exists 2026-03-18 14:49:18 +01:00
Johannes Zellner
ba0bb62fa3 hardcode CLI name for cid-cli in device auth flow 2026-03-18 14:37:15 +01:00
Johannes Zellner
1ca62dd38e Restyle oidc device login views 2026-03-18 14:28:28 +01:00
Johannes Zellner
1b1328c601 Fix more ejs usage in oidc device login views 2026-03-18 11:13:21 +01:00
Johannes Zellner
9633036887 vueify OIDC device views 2026-03-18 10:58:12 +01:00
Girish Ramakrishnan
e3d76ea9f4 uninstall: must continue to teardown other addons 2026-03-18 15:26:06 +05:30
Girish Ramakrishnan
d7212e69b5 unprovision: clear the default backup site 2026-03-18 15:14:11 +05:30
Girish Ramakrishnan
ead58bd6f6 test: use profile to check for passkey 2026-03-18 15:00:45 +05:30
Girish Ramakrishnan
fbe13b75df passkey: fix tests 2026-03-18 14:53:00 +05:30
Girish Ramakrishnan
6085a8231f uninstall: ignore services error as services may never have started 2026-03-18 14:38:47 +05:30
Johannes Zellner
e15cd190b3 Prevent user setup form if passwords dont match 2026-03-18 09:57:56 +01:00
Girish Ramakrishnan
3d55423deb Fix usage of safe() 2026-03-18 14:26:42 +05:30
Girish Ramakrishnan
f62df52c1d passkey: disallow in demo mode 2026-03-18 12:28:57 +05:30
Girish Ramakrishnan
7829f94ac4 update changelog 2026-03-18 11:16:15 +05:30
Girish Ramakrishnan
e9d42b9cdd migration: if no autoupdate setting, use defaults 2026-03-18 11:15:24 +05:30
Girish Ramakrishnan
1f05a8d92a network: fix crash 2026-03-18 07:04:45 +05:30
Johannes Zellner
69ae2b2997 Update eslint to v10 2026-03-17 16:16:00 +01:00
Johannes Zellner
b86e47de02 Update pankow 2026-03-17 16:14:51 +01:00
Girish Ramakrishnan
ea7647f43c oidcserver: fix jwks_rsaonly response 2026-03-17 17:49:52 +05:30
Johannes Zellner
ae7df52780 Reduce font-weight on no apps placeholder 2026-03-17 10:27:09 +01:00
Girish Ramakrishnan
bc5737b9b0 passkey: implement passwordless login 2026-03-16 20:10:59 +05:30
Girish Ramakrishnan
d0745d1914 2fa: provider passkey or totp 2026-03-16 18:49:12 +05:30
Girish Ramakrishnan
2b4c926a70 only clear passkeys on location change
calling this on initialize makes it lose all passkeys
2026-03-16 18:49:01 +05:30
Girish Ramakrishnan
d922c1c80f missing translation for passkey login failure 2026-03-16 17:30:14 +05:30
Girish Ramakrishnan
67500a7689 profile: hasPasskey 2026-03-16 17:20:22 +05:30
Girish Ramakrishnan
1c8aa7440c lint 2026-03-16 17:05:44 +05:30
Girish Ramakrishnan
d128dbec4c fix 2fa translations 2026-03-16 17:04:30 +05:30
Girish Ramakrishnan
676cb8810b loginview: remove reset password from tab order 2026-03-16 16:40:54 +05:30
Girish Ramakrishnan
189e3d5599 allow totp and passkey to co-exist 2026-03-16 16:38:48 +05:30
Girish Ramakrishnan
009d0b39f9 rename twoFactor* to totp 2026-03-16 16:38:42 +05:30
Girish Ramakrishnan
81a8aa7c3d login: move forgot password on top of the password
making way for passkey login
2026-03-16 15:59:00 +05:30
Johannes Zellner
6c6761d14b Update to vite 8 2026-03-16 08:37:19 +01:00
Johannes Zellner
7d2e3df929 Update frontend dependencies 2026-03-16 08:33:23 +01:00
Girish Ramakrishnan
f334c696cb update: add policy to update apps separately from platform 2026-03-16 10:19:18 +05:30
Girish Ramakrishnan
db974d72d5 oidcserver: permit origin "*" from localhost testing 2026-03-16 07:21:55 +05:30
Girish Ramakrishnan
c15e342bb8 webadmin: remove the implicit flow
we now use pkce . main advantage is that we don't see the access token
in the url anymore.

in pkce, the auth code by itself is useless. need the verifier.

fixes #844
2026-03-15 17:38:27 +05:30
Girish Ramakrishnan
dc1449c7b6 oidcserver: convert to trace 2026-03-15 17:32:03 +05:30
Girish Ramakrishnan
0b305caf58 sites: add conflict detection
Fixes #863
2026-03-15 14:59:35 +05:30
Girish Ramakrishnan
8f1f3645b2 app update: if backup fails, provide a notification
fixes #851
2026-03-15 14:48:07 +05:30
Girish Ramakrishnan
0079162efe notifications: only title is bold 2026-03-15 14:26:30 +05:30
Girish Ramakrishnan
7afec06d4c apps: operators can now view backup logs and manage the backup task
we spun off the app backup as a separate task and this is not tracked
by app.taskId .

fixes #856
2026-03-15 10:18:31 +05:30
Girish Ramakrishnan
29f85a8fd2 test: fix debug 2026-03-15 09:54:55 +05:30
Girish Ramakrishnan
6e0dc24eca rsync: escape U+2028/U+2029
JSON strings can contain unescaped U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR
characters while ECMAScript strings cannot. ES2019 now allows those unescaped.

The integrity code assumes that each JSON is a single line but that assumption does
not hold true when these characters are there in the string. Fix is to escape them.
2026-03-15 09:20:40 +05:30
Girish Ramakrishnan
cee1180aa7 9.1.4 changes 2026-03-14 19:10:04 +05:30
Girish Ramakrishnan
6db2b55e63 oidcserver: custom templates for device login
the default one uses google fonts :/
2026-03-13 13:25:57 +05:30
Girish Ramakrishnan
a3c038781f oidc: implement Device Authorization Grant 2026-03-13 12:44:39 +05:30
Girish Ramakrishnan
59c9e5397e ldapserver, directoryserver: all traces 2026-03-12 23:30:12 +05:30
Girish Ramakrishnan
a4c253b9a9 users: modify verify log calls to trace
this is only useful when we are debugging and not useful to end user
without further context
2026-03-12 23:27:13 +05:30
Girish Ramakrishnan
f12b4faf34 lint 2026-03-12 23:23:23 +05:30
Girish Ramakrishnan
ff49759f42 promise-retry: rename api to log 2026-03-12 23:11:16 +05:30
Girish Ramakrishnan
01d0c738bc replace debug() with our custom logger
mostly we want trace() and log(). trace() can be enabled whenever
we want by flipping a flag and restarting box
2026-03-12 23:08:35 +05:30
Girish Ramakrishnan
d57554a48c backup logs: make them much terse and concise
these are making the rsync logs massive. instead resort to reporting
progress based on file count. there is also a heartbeat timer for
"stuck" or "long downloading" files, every minute.
2026-03-12 19:40:46 +05:30
Girish Ramakrishnan
b16b57f38b rsync: throttle log messages during download 2026-03-12 13:47:26 +05:30
Girish Ramakrishnan
12177446a2 backupcleaner: remove cleanupSnapshotSuperfluous
this removes the workaround for the rsync bug to make integrity
checks work. see ec15f29e40 and
2c12bee79b
2026-03-12 08:07:25 +05:30
Girish Ramakrishnan
61b15db958 Update translations 2026-03-12 07:39:45 +05:30
Girish Ramakrishnan
349e8f5139 notifications: add empty text, progress bar and inifinite scroll 2026-03-12 07:33:22 +05:30
Johannes Zellner
f30482808b Workaround chrome quirks on file drop handling 2026-03-11 15:11:16 +01:00
Girish Ramakrishnan
79cdecdff6 graphite: fix aggregation of block/network read/write 2026-03-10 22:56:00 +05:30
Girish Ramakrishnan
336dee53cd metrics: pick last item in series
picking the first item for "max" is not correct
2026-03-10 22:25:15 +05:30
Girish Ramakrishnan
77022bbd7f restore: apply blocklist 2026-03-10 21:34:26 +05:30
Johannes Zellner
df96df776d Wait for dashboard reload when version has changed 2026-03-10 16:28:17 +01:00
Girish Ramakrishnan
67bc803859 troubleshoot: ignore package.json 2026-03-10 20:24:32 +05:30
Girish Ramakrishnan
8ef56c6d91 reverseproxy: fix restore of trusted ips 2026-03-10 17:28:06 +05:30
Girish Ramakrishnan
d377d1e1cf remove deprecated url 2026-03-10 15:15:17 +05:30
Girish Ramakrishnan
4209e4d90d Some progressbars for various views 2026-03-08 19:14:30 +05:30
Girish Ramakrishnan
83c85d02ee services: load all the view services together
box/cloudron service was appearing first and the rest were appearing
a while later
2026-03-08 18:56:57 +05:30
Girish Ramakrishnan
866b72d029 services: distinguish error state and idle state for stopped containers 2026-03-08 18:36:24 +05:30
Girish Ramakrishnan
4bc0f44789 services: lazy start services / on demand services
services are now stopped when no app is using them.

on start up, services are always created and run. they are later
stopped when unused. it's this way to facilitate the upgrade code
path. the database meta files have to be upgraded even if no app is using them.
the other hook to stop unused services is at the end of an app task.

maybe mail container is a candidate for the future where all sending is no-op.
But give this is rare, it's not implemented.
2026-03-08 18:35:50 +05:30
Girish Ramakrishnan
99c55cb22f services: enforce min memory limit 2026-03-05 21:25:31 +05:30
Girish Ramakrishnan
74c73c695f mongodb: set min memory to 2GB 2026-03-05 21:25:31 +05:30
Johannes Zellner
b972891337 If we have no released version of an app also show an error instead of the app install dialog 2026-03-05 16:27:59 +01:00
Girish Ramakrishnan
57515d54db 9.1.3 changes 2026-03-05 16:43:53 +05:30
Johannes Zellner
0ff8dcc8e9 Remove 'Dashboard' from dashboard page title 2026-03-05 12:09:23 +01:00
Girish Ramakrishnan
38efa6a2ba integrity: show failure messages 2026-03-05 16:24:46 +05:30
Johannes Zellner
6306625184 Add vue/no-root-v-if linter rule 2026-03-05 11:53:53 +01:00
Johannes Zellner
1803ab303f Do not use v-if in toplevel nodes of a component 2026-03-05 11:41:23 +01:00
Johannes Zellner
e72dd7c845 Fix warning about missing dom id 2026-03-05 11:40:50 +01:00
Johannes Zellner
87288caeb9 Stop using vue-i18n legacy api 2026-03-05 11:14:18 +01:00
Elias Hackradt
79b519e462 Fix: add label to user.value object 2026-03-05 10:05:02 +00:00
Girish Ramakrishnan
5f8ea2aecc integrity: skip check of backups with no integrity info 2026-03-04 21:18:20 +05:30
Girish Ramakrishnan
94bc52a0c3 rsync: typo 2026-03-04 20:48:00 +05:30
Girish Ramakrishnan
fed51bdcd9 backupintegrity: add percent progress 2026-03-04 18:30:16 +05:30
Girish Ramakrishnan
5fc9689645 backup: fix alignment of progressbar and logs button 2026-03-04 17:24:46 +05:30
Girish Ramakrishnan
7c6a783fc8 tasks: fix progress percents
mostly rebalancing the percents
2026-03-04 16:37:34 +05:30
Girish Ramakrishnan
764d479d7f 9.1.2 changes 2026-03-04 15:26:45 +05:30
Girish Ramakrishnan
ec15f29e40 syncer: clean up superfluous files 2026-03-04 14:00:54 +05:30
Girish Ramakrishnan
2c12bee79b syncer: fix bug with a file and dir having same prefix
if we had say "app/" and "application.json", the syncer logic
was skipping over application.json because it starts with "app".
This would lead to application.json not getting deleted in the snapshot
and result in superfluous files.
2026-03-04 13:59:35 +05:30
Johannes Zellner
1120866b75 Make renewalInfo explicitly fallback to null to be visible in stringify 2026-03-04 08:46:27 +01:00
Johannes Zellner
b362c069e5 fix crash, where result may be undefined on error 2026-03-04 08:39:31 +01:00
Johannes Zellner
4b6b18c182 Try to detect and set the content type of app icons 2026-03-03 22:06:50 +01:00
Johannes Zellner
80efc8c60c Update dashboard dependencies 2026-03-03 21:07:33 +01:00
Girish Ramakrishnan
99168157fc integrity: size is not a function 2026-03-03 20:24:26 +05:30
Girish Ramakrishnan
23c3263562 integrity: show log link
in the previous approach, we used to clear the taskId after
the integrity check completes. for one, we lose track of the
task (to show the logs). for another, we have to clear these
taskId on platform startup to handle crashes.

in the new approach, we keep the taskId and use the task's
active flag to determine if task is active.
2026-03-03 18:41:57 +05:30
Girish Ramakrishnan
1179a78fe1 integrity: center align the indicator 2026-03-03 17:23:16 +05:30
Girish Ramakrishnan
82677ddd85 backup: show integrity column for dependsOn backups 2026-03-03 17:18:28 +05:30
Girish Ramakrishnan
31f29e9086 integrity: show status in the info dialog 2026-03-03 16:57:55 +05:30
Girish Ramakrishnan
3b3e606573 integrity: better log messages 2026-03-03 16:13:44 +05:30
Girish Ramakrishnan
18b713cec3 oom: fix crash when source build ooms 2026-03-02 21:10:47 +05:30
Johannes Zellner
2a6d385cea Support and prefer Dockerfile.cloudron in local builds 2026-03-02 12:06:27 +01:00
Johannes Zellner
4cc1926899 Also increase body upload for app update route to work well with source builds 2026-03-02 11:45:03 +01:00
Johannes Zellner
15b2c2b739 Add Czech translations 2026-03-02 11:36:48 +01:00
Johannes Zellner
197bf56271 Update translations 2026-03-02 11:36:48 +01:00
Girish Ramakrishnan
4110f4b8ce lint 2026-03-02 10:07:31 +05:30
Girish Ramakrishnan
becbaca858 appstore: better tag/cateogry mapping 2026-03-02 09:59:43 +05:30
Girish Ramakrishnan
add50257f6 appstore: add ai category 2026-03-02 09:14:49 +05:30
Girish Ramakrishnan
f061ee5f88 Update modules 2026-03-02 08:40:19 +05:30
Girish Ramakrishnan
480b81b3dd postgres: update pgvector to 0.8.2 2026-03-02 08:37:26 +05:30
Girish Ramakrishnan
61d4a795ae apps: enable storage view in all error states 2026-02-28 02:20:25 +01:00
Girish Ramakrishnan
cd89883dbb apps: move to error state if a volume is unavailable 2026-02-28 01:02:22 +01:00
Girish Ramakrishnan
d5a729a2ba remove the placeholder 2026-02-27 17:10:11 +01:00
Johannes Zellner
b41533c278 Set appsview filters early to avoid flickering 2026-02-27 11:11:11 +01:00
Girish Ramakrishnan
04758587b4 update: throw error when update failed 2026-02-26 11:09:55 +01:00
Johannes Zellner
b6b0969879 Allow to reset app filters when clicking on the menu entry 2026-02-26 11:05:08 +01:00
Johannes Zellner
18ef97fae6 Preserver app search and filter in query args to make back and reload work 2026-02-26 10:55:17 +01:00
Girish Ramakrishnan
333f052f86 backupsites: show warning if no site is selected for automatic backups 2026-02-26 09:03:39 +01:00
Girish Ramakrishnan
7dd40eccf3 backupsites: display automatic update backups flag 2026-02-26 08:25:40 +01:00
Girish Ramakrishnan
db728840a0 backupsite: add space after last run 2026-02-26 08:00:30 +01:00
Girish Ramakrishnan
8906436824 mail: fix css styling of expected value 2026-02-26 02:03:21 +01:00
Johannes Zellner
e8dedb04a5 Clear oidc client add or edit error when dialog opens 2026-02-25 21:16:17 +01:00
Elias Hackradt
d4b581c007 Make MailDomainStatus expected value flexible and add copy button 2026-02-25 17:19:39 +00:00
Johannes Zellner
a900beb3fd First delete mailpassword for oidc clients then the client itself 2026-02-25 18:02:31 +01:00
Johannes Zellner
19a0f77c53 Do not add empty mailclient claim unless requested 2026-02-25 16:15:35 +01:00
Johannes Zellner
6dbd97ba14 Only generate mailpassword and fetch mailboxes if the oidc client wants the mailclient scope 2026-02-25 16:07:46 +01:00
Girish Ramakrishnan
d2fbea8e39 update: typo 2026-02-25 15:53:03 +01:00
Girish Ramakrishnan
86a49c6223 Fix update check translation 2026-02-25 15:24:10 +01:00
Girish Ramakrishnan
e97f9b47d7 weblate: add find 2026-02-25 15:14:38 +01:00
Girish Ramakrishnan
ee3ed5f660 Update lock 2026-02-25 14:53:55 +01:00
Girish Ramakrishnan
3446f3d1e0 add to changes 2026-02-25 14:52:43 +01:00
Girish Ramakrishnan
69d03a7a42 Update pankow 2026-02-25 14:49:56 +01:00
Johannes Zellner
bab95cbefa Use pagination in model code for mailbox listing 2026-02-25 11:24:40 +01:00
Johannes Zellner
5ef23fa49a Bump mailbox list page size up to 10k for now 2026-02-25 10:14:23 +01:00
Girish Ramakrishnan
f4ff63485a domains: validate well known 2026-02-25 05:55:14 +01:00
Girish Ramakrishnan
c20fbe8635 domains: set caldav/cardav correctly on dialog open 2026-02-25 05:46:41 +01:00
Johannes Zellner
662cf65ff2 Only update domains list if component is still mounted 2026-02-24 10:58:07 +01:00
Johannes Zellner
7ded517b20 Protect against crash in EventlogList component on quick unloads 2026-02-24 10:28:13 +01:00
Girish Ramakrishnan
4be31b0dad update: if no backup site, show error message 2026-02-24 05:55:56 +01:00
Girish Ramakrishnan
dc439ba5be install/update: ui must always set the appStoreId or versionsUrl 2026-02-24 05:31:13 +01:00
Girish Ramakrishnan
5ba8a05450 cifs: use rsync instead cp -aRl
for some reason, cp fails on synology cifs for some paths - immich's typesense dir
2026-02-23 17:47:05 +01:00
Girish Ramakrishnan
7ef19b318a add weblate script 2026-02-23 14:45:22 +01:00
Girish Ramakrishnan
2ac76ad852 2fa dialog translations updates 2026-02-23 14:45:14 +01:00
Girish Ramakrishnan
f4598f81c9 debug: add CLOUDRON_DEBUG=1 env var 2026-02-23 10:53:06 +01:00
Girish Ramakrishnan
c432dbb5bc Update translations 2026-02-23 10:50:29 +01:00
Girish Ramakrishnan
d0f0bb799e 2fa: refactor into separate dialog
also rename routes to totp
2026-02-22 10:43:15 +01:00
Girish Ramakrishnan
a98dbfdf4f integrity: link to the logs 2026-02-22 10:24:32 +01:00
Girish Ramakrishnan
a71909acd3 Update lock file 2026-02-21 20:34:24 +01:00
Girish Ramakrishnan
ea5953a397 Fix test 2026-02-21 20:27:55 +01:00
Girish Ramakrishnan
4ad9ccabe0 apps: refactor the updateInfo properties 2026-02-21 20:01:05 +01:00
Girish Ramakrishnan
17640d44fa Update changelog 2026-02-21 19:43:23 +01:00
Girish Ramakrishnan
812d471573 add backupCommand, restoreCommand, persistentDirs 2026-02-21 19:42:52 +01:00
Girish Ramakrishnan
fa981d5a83 release: replace url with URL 2026-02-21 16:46:29 +01:00
Girish Ramakrishnan
202f2c6cb0 updater: keep updateInfo response same as in the app object 2026-02-21 16:43:03 +01:00
Girish Ramakrishnan
55359bfa24 Update haraka to 3.1.3 2026-02-21 14:13:50 +01:00
Girish Ramakrishnan
95fcfce9cd add "community" to packager info 2026-02-21 12:12:36 +01:00
Girish Ramakrishnan
3120a2c43f appstore: more state information 2026-02-21 12:05:56 +01:00
Girish Ramakrishnan
7ba3a59dea eventlog: add flag for source builds 2026-02-21 11:48:39 +01:00
Girish Ramakrishnan
eb5f8fcfa1 community: better resolution of url 2026-02-21 11:17:47 +01:00
Girish Ramakrishnan
5014227028 community: validateForm on dialog open 2026-02-21 11:11:34 +01:00
Girish Ramakrishnan
7a76de2e4c reorder menu items to be alphabetical 2026-02-21 11:11:34 +01:00
Johannes Zellner
de5692c1af Immediately refresh the app after checking for updates 2026-02-20 22:27:30 +01:00
Johannes Zellner
555e4f0e65 Only set postinstall pending in localstorage if a message exists in the manifest 2026-02-20 22:17:10 +01:00
Girish Ramakrishnan
723c670100 reverseproxy: fix crash 2026-02-20 19:46:43 +01:00
Johannes Zellner
2f951dc272 Support card/cal dav well-known endpoints 2026-02-20 15:46:43 +01:00
Girish Ramakrishnan
0daabdc21c reverseproxy: for large payloads, turn off proxy buffering 2026-02-20 09:59:21 +01:00
Girish Ramakrishnan
38a187e9fc apps: install route needs more upload limit 2026-02-20 09:54:13 +01:00
Girish Ramakrishnan
5a613231e0 apps: remove deprecated /api/v1/apps/install route 2026-02-20 09:48:07 +01:00
Girish Ramakrishnan
28a35e7260 community: handle unstable flag 2026-02-19 22:48:16 +01:00
Girish Ramakrishnan
461a5a780d community: resolve user provided url 2026-02-19 22:25:43 +01:00
Girish Ramakrishnan
207260821b reverseproxy: add forceRenewal check if only for completeness 2026-02-19 22:25:43 +01:00
Johannes Zellner
466527884f Fix acl logic for token inspection endpoint 2026-02-19 19:09:02 +01:00
Johannes Zellner
9d03eb2643 Check internal ACL during token introspection 2026-02-19 18:05:49 +01:00
Johannes Zellner
c801202642 Fix variable usage after no-shadow fixes 2026-02-19 18:05:36 +01:00
Girish Ramakrishnan
95952fae75 update manifestformat 2026-02-19 14:40:29 +01:00
Girish Ramakrishnan
f62629b513 mailpassword: fix test, remove unused function 2026-02-19 14:22:36 +01:00
Girish Ramakrishnan
f04087815c Fix failing test 2026-02-19 13:48:48 +01:00
Girish Ramakrishnan
255b1c63d0 Update modules 2026-02-19 13:36:26 +01:00
Girish Ramakrishnan
9b5b8ddc22 dockerproxy: close all connections 2026-02-19 13:33:02 +01:00
Girish Ramakrishnan
d0a66f1701 Back to mocha!
sorry i ever left you dear mocha

node:test has two major issues:
* --bail does not work and requires strange modules and incantations.
I was able to work around this with a custom module.

* the test reporter reports _after_ the suite is run. this makes debugging
really hard. the debugs that we print all happen before the test suite summary.
poor design overall.
2026-02-19 13:24:14 +01:00
Girish Ramakrishnan
c176ac600b migrate tests to node:test 2026-02-19 10:13:22 +01:00
Girish Ramakrishnan
cf0ab16533 fix test 2026-02-18 20:20:26 +01:00
Girish Ramakrishnan
03d0e2157e Update modules 2026-02-18 19:59:58 +01:00
Girish Ramakrishnan
cdd5137ebe Update addons to latest builds 2026-02-18 17:44:18 +01:00
Johannes Zellner
0a924b2c29 Cleanup mailPasswords when oidc client is removed not only when an app is uninstalled 2026-02-18 17:15:06 +01:00
Johannes Zellner
43acecfc6e mailPasswords table should work with oidc clients not apps 2026-02-18 15:17:08 +01:00
Johannes Zellner
5e7e739589 Enable token inspection endpoints in oidc 2026-02-18 15:00:02 +01:00
Elias Hackradt
0b968b6a98 Use branding.getCloudronName(); for totp secret name metadata 2026-02-18 13:19:27 +00:00
Johannes Zellner
f14dfb6c17 Fix typo 2026-02-18 11:27:45 +01:00
Johannes Zellner
cb5ccd8166 Also auth against mailPasswords in ldapserver.js 2026-02-18 10:12:34 +01:00
Johannes Zellner
bfbcbb686d Send an email accessToken alongside the mailclient claims 2026-02-18 10:12:34 +01:00
Johannes Zellner
744300744c Fix claim name to mailclient 2026-02-18 10:12:34 +01:00
Johannes Zellner
9bac099339 Add mailPassword table
This table stores email credentials for users using apps which use the
email addon
2026-02-18 10:12:34 +01:00
Johannes Zellner
135c9fb64d Support mailclient oidc claim
Only apps with addon email have access to the claims' scopes
2026-02-18 10:12:34 +01:00
Girish Ramakrishnan
4ed6fbbd74 eslint: add no-shadow 2026-02-18 08:18:37 +01:00
Girish Ramakrishnan
4d3e9dc49b Fix shadow of pipeline variable 2026-02-18 07:58:47 +01:00
Girish Ramakrishnan
319360f8d0 lint 2026-02-17 19:51:09 +01:00
Girish Ramakrishnan
3ef990b0bf Fix typo 2026-02-17 18:48:07 +01:00
Girish Ramakrishnan
b8ae46b6df add passkey tests 2026-02-17 18:05:14 +01:00
Girish Ramakrishnan
113aba0897 remove the cors test 2026-02-17 17:15:30 +01:00
Girish Ramakrishnan
a51672f3ee security: remove cors
I traced this back to a commit from 2014! 781495e662
2026-02-17 17:10:18 +01:00
Girish Ramakrishnan
f08b3eb006 mail: bring eventlog up to speed 2026-02-17 16:18:37 +01:00
Girish Ramakrishnan
66f65093fc eventlog: fix the description in app context 2026-02-17 15:41:09 +01:00
Girish Ramakrishnan
d78944e03b eventlog: add volume remount 2026-02-17 15:32:22 +01:00
Girish Ramakrishnan
2fe31b876f Add EVENTS to constants 2026-02-17 15:30:44 +01:00
Girish Ramakrishnan
9949ea364a eventlog: move to table view 2026-02-17 15:12:45 +01:00
Girish Ramakrishnan
77b7f7bfad eventlog: make a component and use it in app and system 2026-02-17 14:42:40 +01:00
Girish Ramakrishnan
8d4b458a22 eventlog: fix date range and empty string search 2026-02-17 13:49:10 +01:00
Girish Ramakrishnan
2df8e77733 eventlog: implement contextual highlight 2026-02-17 13:21:06 +01:00
Johannes Zellner
c21011a17a support ID_CLI oidc client 2026-02-16 23:19:37 +01:00
Girish Ramakrishnan
a11a691788 eventlog: add to/from date picker 2026-02-16 22:03:18 +01:00
Girish Ramakrishnan
81659d4bf2 eventlog: add params for from and to date 2026-02-16 20:52:02 +01:00
Girish Ramakrishnan
aab20fd23e filemanager: move the title to top left like terminal 2026-02-16 14:21:46 +01:00
Johannes Zellner
5fad4dd034 Ensure users without totp nor passkey are forced to setup one if mandatory2FA is enabled 2026-02-16 14:01:14 +01:00
Johannes Zellner
7bc19e8185 Cleanup passkeys on user deletion 2026-02-16 13:52:04 +01:00
Johannes Zellner
45d0928ff9 Ask the user for confirmation on dashboard domain change 2026-02-16 12:33:09 +01:00
Johannes Zellner
9b768273f4 provide a global InputDialog for dashboard views 2026-02-16 12:32:43 +01:00
Johannes Zellner
ef24b17a70 Drop all passkeys if the dashboard domain changes 2026-02-16 12:06:12 +01:00
Girish Ramakrishnan
dfbe5aaa16 filemanager: remove breadcrumb now that we have treeview 2026-02-16 11:29:35 +01:00
Girish Ramakrishnan
f499c9ada9 integrity: add eventlog status for historic checks 2026-02-15 23:40:23 +01:00
Girish Ramakrishnan
c1a73aa62a integrity: just clear last info on a (re)start
this way if a user stops it midway, the old info is cleared
2026-02-15 23:26:06 +01:00
Girish Ramakrishnan
601e787500 rsync: fix integrity check 2026-02-15 23:17:23 +01:00
Girish Ramakrishnan
d24bfabdc1 info: only show packager for custom apps 2026-02-15 20:14:05 +01:00
Girish Ramakrishnan
2c559d63f5 Fix bugs in esm migration 2026-02-15 20:11:58 +01:00
Girish Ramakrishnan
b5a1554631 Fix various linter errors 2026-02-15 19:37:30 +01:00
Girish Ramakrishnan
510e1c7296 Fix missing import 2026-02-15 19:21:06 +01:00
Girish Ramakrishnan
c6d8af5dc3 add to changelog 2026-02-15 19:05:40 +01:00
Girish Ramakrishnan
adf884c2c4 add integrity check to system backups 2026-02-15 14:59:27 +01:00
Girish Ramakrishnan
c7b321315c integrity: add stats 2026-02-15 14:41:10 +01:00
Girish Ramakrishnan
9f2eefcbb3 embed integrity check task in backup API responses
The UI is polling for the taskId, might as well attach it
2026-02-15 14:11:56 +01:00
Girish Ramakrishnan
fc2e39f41b Rename getByIdentifierAndStatePaged to listByIdentifierAndStatePaged 2026-02-15 12:22:43 +01:00
Girish Ramakrishnan
eae86d15ef Update modules 2026-02-14 19:49:42 +01:00
Girish Ramakrishnan
361d80da17 make tests work again 2026-02-14 19:45:10 +01:00
Girish Ramakrishnan
2597402496 make build work across server restart
tmp files disappear on server restart
2026-02-14 19:37:14 +01:00
Girish Ramakrishnan
c8bc6f9ffe Update translations 2026-02-14 18:33:50 +01:00
Girish Ramakrishnan
b0ef9238ff Show proper title/description for the new start/stop section 2026-02-14 18:31:06 +01:00
Girish Ramakrishnan
b71e503a01 more ESM cleanups 2026-02-14 16:52:16 +01:00
Girish Ramakrishnan
e9f96593c3 reorder functions for no-use-before-define 2026-02-14 16:34:34 +01:00
Girish Ramakrishnan
36aa641cb9 migrate to "export default"
also, set no-use-before-define in linter
2026-02-14 15:43:24 +01:00
Girish Ramakrishnan
ddb46646fa remove esm migration files 2026-02-14 15:12:07 +01:00
Girish Ramakrishnan
96dc79cfe6 Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
  (dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
  declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
  loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing

Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:11:45 +01:00
Girish Ramakrishnan
e0e9f14a5e mail: increase solr timeout 2026-02-14 01:23:23 +01:00
Johannes Zellner
b24e1142f8 Move app stop/start into uninstall again and add restart button in main toolbar 2026-02-13 18:37:49 +01:00
Johannes Zellner
0543b16de9 Update frontend dependencies 2026-02-13 17:51:36 +01:00
Johannes Zellner
8d46c09f95 Update pankow 2026-02-13 17:38:56 +01:00
Johannes Zellner
5724ca73b4 Add passkey support 2026-02-13 17:18:56 +01:00
Girish Ramakrishnan
3e09bef613 folderview: implement drop handler 2026-02-12 23:44:23 +01:00
Girish Ramakrishnan
627b1fe33f filemanager: implement tree view on the left 2026-02-12 22:55:24 +01:00
Girish Ramakrishnan
1aa270485c add changelog 2026-02-12 20:31:51 +01:00
Girish Ramakrishnan
ae09c19b69 filemanager: open terminal in cwd 2026-02-12 20:30:57 +01:00
Johannes Zellner
c5cf8eef1a Update pankow to v4 to fix TableView bug 2026-02-12 20:28:12 +01:00
Girish Ramakrishnan
e76d4b3474 tests: fix app passwords test 2026-02-12 19:57:34 +01:00
Girish Ramakrishnan
88a44ee065 oidc: add alg to the jwks keys 2026-02-12 19:42:00 +01:00
Girish Ramakrishnan
51e02da277 spaces: add missing regions 2026-02-12 17:15:08 +01:00
Girish Ramakrishnan
e9c3e42aa6 appPassword: add expiry 2026-02-12 16:23:31 +01:00
Girish Ramakrishnan
93a0063941 backups: add stop_integrity_check route 2026-02-09 22:00:40 +01:00
Girish Ramakrishnan
26a3cf79c5 backup: show last integrity in info dialog 2026-02-09 21:46:04 +01:00
Johannes Zellner
26999afc22 Reduce SaveIndicator timeout to 1500ms 2026-02-09 20:43:44 +01:00
Johannes Zellner
81729e4b2a Add SaveIndicator to email settings 2026-02-09 20:43:34 +01:00
Johannes Zellner
4bae5ee2fb Add SaveIndicator to user directory settings 2026-02-09 20:34:18 +01:00
Johannes Zellner
a786e6c8f5 Do not crash if we have a new user without a username yet 2026-02-09 20:34:05 +01:00
Johannes Zellner
3803f36aa5 Fix language change to han chinese 2026-02-09 20:27:12 +01:00
Johannes Zellner
55bc26bd09 Add error state for SaveIndicator and put it in more places 2026-02-09 20:25:13 +01:00
Girish Ramakrishnan
d84037a0dd add to changes 2026-02-09 18:24:56 +01:00
Girish Ramakrishnan
1ce5fcafd9 apppassword: display error 2026-02-09 17:52:40 +01:00
Johannes Zellner
281233f48b Unset align-items for section header toolbar to make buttons fill whole height 2026-02-09 17:29:12 +01:00
Johannes Zellner
68d73e088d Move notification settings to notifications view 2026-02-09 17:06:17 +01:00
Girish Ramakrishnan
b433191b35 community: skip revoked versions 2026-02-09 16:11:55 +01:00
Girish Ramakrishnan
d75ad44315 mail: fix haraka crash 2026-02-09 16:04:13 +01:00
Girish Ramakrishnan
c3d3c3a6e9 app: if repo changes, do not autoupdate 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan
b9b8ccb8ae community: stable/unstable 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan
5a56a7c8af community: show packager info 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan
d4efb63f3d Update packages 2026-02-09 15:51:47 +01:00
Johannes Zellner
2ec349e919 Remove duplicate code 2026-02-09 15:33:10 +01:00
Johannes Zellner
772770273a Set tls provider upon dns provider change, not in a watcher, which has side-effects 2026-02-09 14:32:02 +01:00
Girish Ramakrishnan
fa5cbfc304 mail: allow comma in email display name 2026-02-09 11:01:47 +01:00
Girish Ramakrishnan
5276321ade integrity: add integrity check fields and initial UI 2026-02-08 23:26:57 +01:00
Girish Ramakrishnan
6303602323 graphite: update go-carbon to 0.19.1 2026-02-07 12:01:25 +01:00
Girish Ramakrishnan
486fb0d10a collectd is gone 2026-02-07 11:45:12 +01:00
Girish Ramakrishnan
74b8a08251 add community app warning 2026-02-06 22:49:08 +01:00
Girish Ramakrishnan
2a244bb8d4 Update versions format to have a root 2026-02-06 22:39:29 +01:00
Girish Ramakrishnan
84e73943f7 typo 2026-02-06 22:34:42 +01:00
Girish Ramakrishnan
ace09ca5a7 add versions url in the menu 2026-02-06 19:19:04 +01:00
Girish Ramakrishnan
a9ae34b149 community: download iconUrl
also rename existing db field appStoreIcon to packageIcon
2026-02-06 19:13:55 +01:00
Girish Ramakrishnan
cff778fe6a set fallback image url 2026-02-06 19:08:23 +01:00
Girish Ramakrishnan
be69f9f8a3 minor package updates 2026-02-06 18:23:30 +01:00
Girish Ramakrishnan
5ca2078461 add iconUrl to manifest 2026-02-06 18:04:47 +01:00
Girish Ramakrishnan
4461e7225f validate versions file 2026-02-06 17:35:59 +01:00
Girish Ramakrishnan
49d5d10d77 typo 2026-02-06 16:17:22 +01:00
Girish Ramakrishnan
84374f03e9 cloudron-setup: fix the echo 2026-02-06 12:10:09 +01:00
Girish Ramakrishnan
f8a44014f7 installer: check for process instead of locks 2026-02-06 12:02:46 +01:00
Girish Ramakrishnan
6befb64691 cloudron-setup: wait for apt setup 2026-02-06 12:01:14 +01:00
Girish Ramakrishnan
1ff2c21c61 Move custom app actions to a dropdown 2026-02-06 10:32:29 +01:00
Girish Ramakrishnan
c79d4a24c4 community: allow version in the url input 2026-02-06 10:19:13 +01:00
Girish Ramakrishnan
3d7a5676d8 lint 2026-02-05 23:22:26 +01:00
Girish Ramakrishnan
aa362477e8 community: validate the url in the dialog 2026-02-05 22:40:37 +01:00
Johannes Zellner
13b524e8a5 Fix bold/strong font weight in markdown 2026-02-05 21:57:20 +01:00
Girish Ramakrishnan
d6eb6d3e3e community: store versionsUrl in the database 2026-02-05 19:32:29 +01:00
Girish Ramakrishnan
91b8f1a457 oidc: do not fail on notification failure 2026-02-05 18:26:14 +01:00
Johannes Zellner
c8cdcfc99f Fix const usage of reactive() variable 2026-02-05 15:14:15 +01:00
Johannes Zellner
fe20e738cd Update vite and vue related dependencies 2026-02-05 15:11:09 +01:00
Johannes Zellner
e23856bf10 Fixup fonts 2026-02-05 15:08:33 +01:00
Girish Ramakrishnan
a7de7fb286 initial implementation of community packages 2026-02-05 14:21:50 +01:00
Johannes Zellner
a931d2a91f Attempt to improve fonts on chrome 2026-02-05 13:33:37 +01:00
Girish Ramakrishnan
c94c66b71e do not use displayName since it may not be unique 2026-02-04 18:32:45 +01:00
Girish Ramakrishnan
cb89c30591 Fix help url 2026-02-04 18:16:38 +01:00
Elias Hackradt
1012c0f654 Fix Applink ACL 2026-02-04 16:57:51 +00:00
Girish Ramakrishnan
9b5fb9ae8f applink: do not send label when not set 2026-02-04 14:53:50 +01:00
Girish Ramakrishnan
c4055271a8 render email as plain text in checklist and postinstall 2026-02-04 14:45:50 +01:00
Johannes Zellner
cd1df37ed3 Show save indicator for language and timezone submission 2026-02-04 12:43:58 +01:00
Johannes Zellner
3d8d4fd921 Show error overlay with link to services view if filemanager addon is down 2026-02-04 12:00:03 +01:00
Girish Ramakrishnan
17b0c3e48d services: Graphite -> Metrics 2026-02-04 11:33:40 +01:00
Johannes Zellner
f364257db9 Do not send username for profile updates if it isn't set yet 2026-02-03 19:57:23 +01:00
Johannes Zellner
6b0d9f8551 Fix vue warning about wrong props type 2026-02-03 18:47:17 +01:00
Johannes Zellner
f0fb420a8d Skip checklist db migration script if no owner exists 2026-02-03 18:41:39 +01:00
Johannes Zellner
8aa2695263 Avoid horizontal scrolling in volumes table 2026-02-03 18:32:11 +01:00
Johannes Zellner
b9af8ee6be Send the service name when listing services 2026-02-03 18:32:11 +01:00
Girish Ramakrishnan
7077289840 Fix doc link 2026-02-03 18:26:15 +01:00
Johannes Zellner
bdd35fb02a Go to app configure view after installation started 2026-02-03 11:30:35 +01:00
Girish Ramakrishnan
47660c5679 Update translations 2026-02-03 08:53:00 +01:00
Johannes Zellner
28573f9676 Right align table headers for backup content info 2026-02-02 16:41:47 +01:00
Johannes Zellner
375b7f6dd7 Let appstore filter bar wrap for mobile 2026-02-02 16:32:58 +01:00
Johannes Zellner
99ec2d5ce7 Use Section component for NotificationView to work better on mobile 2026-02-02 16:31:25 +01:00
Johannes Zellner
f2afd654f8 Bring back reboot button in reboot required notification 2026-02-02 16:21:31 +01:00
Johannes Zellner
d42919285b Add user filter for roles and invited status 2026-02-02 12:25:09 +01:00
Johannes Zellner
33a1f135e0 Hide quickactions on mobile 2026-02-02 11:41:50 +01:00
Johannes Zellner
214b836d13 Hide user roles on mobile 2026-02-02 11:36:19 +01:00
Johannes Zellner
408a07e8b9 Update pankow for tooltip fix 2026-02-02 10:41:02 +01:00
Girish Ramakrishnan
b247731062 doc links have changed 2026-02-02 10:19:19 +01:00
Johannes Zellner
dcaa484929 Show user roles always as separate column 2026-02-02 10:11:15 +01:00
Girish Ramakrishnan
35886633e5 appinstall: align the header text and icon 2026-02-02 06:40:43 +01:00
Johannes Zellner
d04afc26e7 Add db migration to ensure done checklist items have an owner and timestamp 2026-02-01 13:57:55 +01:00
Johannes Zellner
bb5f1b703e Update lock file 2026-02-01 13:20:10 +01:00
Girish Ramakrishnan
dfe2d27709 comment out some logs 2026-01-31 12:09:55 +01:00
Girish Ramakrishnan
1dec4f0070 cloudron-support: try ipv4 and ipv6 image pull 2026-01-30 17:20:35 +01:00
Girish Ramakrishnan
89b6513217 installer: need at least ubuntu 22 2026-01-30 09:55:53 +01:00
Girish Ramakrishnan
16a8caa8db installer: better log messages 2026-01-30 09:54:33 +01:00
Girish Ramakrishnan
1594d190eb update: skip backup site check when skipBackup is true 2026-01-30 09:52:52 +01:00
Girish Ramakrishnan
3333f70a64 9.0.18 changes
(cherry picked from commit fd881b4c61)
2026-01-29 17:38:55 +01:00
Johannes Zellner
5bd803e6b4 hetznercloud wants name instead of search_name for zones 2026-01-29 15:35:29 +01:00
Girish Ramakrishnan
b5f5b096d4 ami: do not set domain provider by default 2026-01-29 14:20:53 +01:00
Girish Ramakrishnan
dce05140bf ami: add instanceId input box 2026-01-29 13:50:27 +01:00
Girish Ramakrishnan
29d5ac94b2 activation: remove unused setupToken 2026-01-29 13:17:12 +01:00
Girish Ramakrishnan
2b80c6c1ad setup: setupToken is not used anymore 2026-01-29 13:15:09 +01:00
Girish Ramakrishnan
94a62b040b setup: set initial value for tls config 2026-01-29 13:13:35 +01:00
Johannes Zellner
2bb9c50db9 Condense info in app install dialog 2026-01-29 11:58:09 +01:00
Girish Ramakrishnan
6533ba4581 appstore: include provider as part of state 2026-01-29 11:46:57 +01:00
Girish Ramakrishnan
081909572e Fix casing 2026-01-28 15:52:15 +01:00
Johannes Zellner
aa84cb0079 Actually make multiplart also optional 2026-01-28 14:17:22 +01:00
Girish Ramakrishnan
a66c3700b3 Fix doc urls 2026-01-28 12:07:04 +01:00
Johannes Zellner
70476bd168 Use jsonOrMultipart instead of jsonOptional and multipart 2026-01-27 22:01:18 +01:00
Johannes Zellner
a7929e142f Build local image for updates in apptask 2026-01-27 22:01:18 +01:00
Johannes Zellner
fd0d65b8ce Keep the app source archive with the app instance data dir 2026-01-27 22:01:18 +01:00
Johannes Zellner
ef2a94c2c8 use local/id:version-ts as docker image tag for locally built apps 2026-01-27 22:01:18 +01:00
Johannes Zellner
b43daf2f08 Use the uploaded app source tarball to build a local docker image 2026-01-27 22:01:18 +01:00
Johannes Zellner
280f628746 Accept json body or formdata in app install route 2026-01-27 22:01:18 +01:00
Johannes Zellner
713774c03f Attempt to parse formdata fields as json 2026-01-27 22:01:18 +01:00
Johannes Zellner
0889c1531e Optionally allow json middleware to not check the content-type 2026-01-27 22:01:18 +01:00
Girish Ramakrishnan
dbd5810a08 Add to changes 2026-01-26 22:52:57 +01:00
Johannes Zellner
c8722e9945 Update translations 2026-01-23 23:34:41 +01:00
Girish Ramakrishnan
87780a2fc8 sftp: update modules 2026-01-23 16:52:52 +01:00
Johannes Zellner
ab03256db0 Update translations 2026-01-23 15:00:17 +01:00
Johannes Zellner
e26640c80e Use translations in notification view 2026-01-23 15:00:08 +01:00
Johannes Zellner
e6806453e1 Add pagination to NotificationsView 2026-01-23 14:24:31 +01:00
Johannes Zellner
d0fb2583a5 Show unread notifications by default and purge them on read 2026-01-23 14:08:46 +01:00
Johannes Zellner
c4f8f318af Add unread/showall filter for notifications 2026-01-23 13:57:15 +01:00
Girish Ramakrishnan
a6286bb67e mongodb: fcv fix
have to upgrade mongodb to 8.0 first
2026-01-23 13:48:01 +01:00
Johannes Zellner
90aea9708c Update lock file 2026-01-23 13:03:41 +01:00
Johannes Zellner
cb076123b3 Only run quickaction action if set 2026-01-23 13:03:12 +01:00
Johannes Zellner
70a9a66ae9 Do not order notifcations by check/unchecked 2026-01-23 13:03:12 +01:00
Girish Ramakrishnan
8521a47cfa mysql: pipework update 2026-01-23 12:32:47 +01:00
Johannes Zellner
106cc5238e Keep unread notifications in sync with headerbar 2026-01-23 12:26:12 +01:00
Girish Ramakrishnan
2040eb22a2 mail: update modules 2026-01-23 12:14:33 +01:00
Girish Ramakrishnan
b6075a9765 redis: update to 8.4.0 2026-01-23 11:34:41 +01:00
Girish Ramakrishnan
daacbcb89d mongodb: update to 8.2.3 2026-01-23 11:26:30 +01:00
Girish Ramakrishnan
6d622bbd14 postgres: pipework update 2026-01-23 11:18:06 +01:00
Girish Ramakrishnan
f355da4874 Fix up changelog 2026-01-23 10:03:25 +01:00
Girish Ramakrishnan
4b36de5200 Update docker to 29.1.5 2026-01-23 10:02:57 +01:00
Girish Ramakrishnan
88d37e99aa docker: compare debian package version instead of docker version
this way after an ubuntu upgrade, we update it with the correct
docker package on the next cloudron upgrade
2026-01-23 09:30:44 +01:00
Girish Ramakrishnan
1608fc3fdc Update packages 2026-01-23 09:13:11 +01:00
Girish Ramakrishnan
057fd18139 Update nodejs to 24.13.0 2026-01-23 09:04:57 +01:00
Johannes Zellner
b6371a0bdf Add standalone NotificationsView 2026-01-22 18:56:06 +01:00
Girish Ramakrishnan
03fe72e0b1 services: fix usage of pipework 2026-01-22 18:09:41 +01:00
Johannes Zellner
3bf4bddc10 Remove very old ubuntu 16 and 18 from cloudron-setup 2026-01-22 12:23:45 +01:00
Johannes Zellner
92dcf19511 Fix form submission for app clone dialog 2026-01-22 10:26:13 +01:00
Girish Ramakrishnan
b238443a9d services: switch to using @cloudron/pipework
also improve the error messages along the way
2026-01-21 21:32:02 +01:00
Girish Ramakrishnan
021a39a964 services: destroy the read/write stream on error 2026-01-21 17:23:27 +01:00
Girish Ramakrishnan
72c494e9dc backuptask: fix usage of upload API 2026-01-21 09:48:21 +01:00
Girish Ramakrishnan
42cefd56eb Fix uploader API to handle write stream errors
When the upload is aborted/abandoed because the source file is missing,
the write stream is never destroyed. This dangling write stream can
later error and cause a crash.

Instead, create the write stream on demand and nearer to pipeline() to
make sure we do 'error' handling.
2026-01-20 22:26:08 +01:00
Johannes Zellner
944f163882 Fix QuickAction width in app backup view 2026-01-20 18:08:35 +01:00
Girish Ramakrishnan
11a8a73723 app install: Fix status of install button 2026-01-20 17:49:35 +01:00
Girish Ramakrishnan
e34cf8f6a6 location: fix up form validation 2026-01-20 17:23:08 +01:00
Girish Ramakrishnan
7f8143f06f services: set some width to avoid column shifting
(cherry picked from commit 529d227e74)
2026-01-18 18:27:51 +01:00
Girish Ramakrishnan
472e513a9f postgresql: reindex fix
(cherry picked from commit 6b56efcf14)
2026-01-18 18:27:44 +01:00
Girish Ramakrishnan
1cbacab3a2 services: keep quick actions consistent
(cherry picked from commit f23c8a9243)
2026-01-18 18:27:38 +01:00
Girish Ramakrishnan
49bbb8588d 9.0.17 changes
(cherry picked from commit 98660567e5)
2026-01-18 11:48:30 +01:00
Girish Ramakrishnan
23e0fe5791 postgres: fix hook that upgrades vectorchord 2026-01-18 11:36:48 +01:00
Girish Ramakrishnan
6877dfb772 acme: ARI support
ARI is a hint from the cert issuer about when to renew a cert. We will
use it when the API is available.

It provides a mechanim for CAs to revoke certs and signal to clients
that cert should be renewed.

https://www.rfc-editor.org/rfc/rfc9773.txt
https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients
2026-01-17 23:28:47 +01:00
Girish Ramakrishnan
f65b33f3fc Fix key type typo 2026-01-17 22:20:39 +01:00
Girish Ramakrishnan
3daddf2fe6 update superagent 2026-01-17 22:18:27 +01:00
Girish Ramakrishnan
efccf2729b start moving openssl commands into openssl.js 2026-01-17 15:28:44 +01:00
Girish Ramakrishnan
3a1cd8f67f acme2: do not rely on db 2026-01-17 13:21:50 +01:00
Girish Ramakrishnan
53c90429d3 acme: rename variable 2026-01-17 10:26:04 +01:00
Girish Ramakrishnan
7b5384a7d5 acme2: add profile support
this adds acme profile support which was GA today.

https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability
https://letsencrypt.org/docs/profiles/
2026-01-17 09:51:29 +01:00
Girish Ramakrishnan
2b362d8eaf users: add note about invitationToken
this is a one time token that is valid until the account is set up.
this is the reason it has no expiry time.
2026-01-17 09:44:43 +01:00
Girish Ramakrishnan
ce0024a43c Update translations 2026-01-16 15:07:52 +01:00
Girish Ramakrishnan
888696975d diskusage: hide last updated when refreshing 2026-01-16 15:07:48 +01:00
475 changed files with 30404 additions and 22883 deletions

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ installer/src/certs/server.key
# vim swap files
*.swp
.cursor

114
CHANGES
View File

@@ -3110,3 +3110,117 @@
* mail: update haraka to 3.1.2
* csp/robots: add common patterns
[9.0.17]
* Update mongodb to 7.0.28 (also fixes mongobleed)
* UI: add favorites for list views
* UI: add collapsible sidebar
* docker: do not use auth for appstore images
* backup: add synology C2
* mail: update haraka to 3.1.2
* csp/robots: add common patterns
[9.0.18]
* ami & cloud images: fix setup
[9.1.0]
* acme: ARI support . https://www.rfc-editor.org/rfc/rfc9773.txt
* Update nodejs to 24.13.0
* Update docker to 29.1.5
* Update mongodb to 8.0.17
* Update redis to 8.4.0
* Add notification view. settings have moved to this new view.
* updater: skip backup site check when user skips backup
* community packages
* source builds
* backups: add integrity check UI
* Fix fonts on chrome
* applinks: fix acl UI
* services: rename sftp to filemanager, graphite to metrics
* app passwords: add expiry
* DO Spaces: add missing ATL1, BLR1, SYD1 regions
* filemanager: the terminal button automatically cds into the cwd
* filemanager: add a tree view
* passkey support
* security: remove cors
* support card/cal dav well-known endpoints
* add backupCommand, restoreCommand, persistentDirs
* Update Haraka to 3.1.3
[9.1.1]
* cli: use web based browser login flow
[9.1.2]
* apps: avoid flickering with filters
* apps: move to error state if a volume is unavailable
* apps: enable storage view in all error states
* postgres: update pgvector to 0.8.2
* appstore: add ai category
* appstore: better tag/cateogry mapping
* i18n: add Czech translations
* Support and prefer Dockerfile.cloudron in local builds
* integrity: show status in the info dialog
* 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

106
PLAN.md Normal file
View File

@@ -0,0 +1,106 @@
# Plan: Adding PowerDNS as a DNS Provider to Cloudron
This document outlines the detailed steps required to add support for PowerDNS via the PowerDNS Authoritative Server REST API to Cloudron. It covers frontend UI updates, backend DNS provider implementation, unit testing, and live testing instructions.
## 1. Frontend Modifications
The Cloudron dashboard is a Vue application that needs to know about the new provider and how to prompt the user for credentials.
### `dashboard/src/models/DomainsModel.js`
* **Add Provider:** Append `{ name: 'PowerDNS', value: 'powerdns' }` to the `providers` array.
* **Define Required Properties:** In the `getProviderConfigProps(provider)` switch statement, add a case for PowerDNS to define the fields it needs:
```javascript
case 'powerdns':
props = ['apiUrl', 'apiKey'];
break;
```
### `dashboard/src/components/DomainProviderForm.vue`
* **Reset Logic:** In the `dnsConfig` reset block (around line 57), add `dnsConfig.value.apiUrl = '';` to ensure the form clears properly.
* **UI Template:** Add the HTML form groups for PowerDNS within the template section:
```html
<!-- powerdns -->
<FormGroup v-if="provider === 'powerdns'">
<label for="powerdnsApiUrlInput">{{ $t('domains.domainDialog.powerdnsApiUrl') }}</label>
<TextInput id="powerdnsApiUrlInput" type="url" v-model="dnsConfig.apiUrl" placeholder="https://ns1.example.com:8081" required />
</FormGroup>
<FormGroup v-if="provider === 'powerdns'">
<label for="powerdnsApiKeyInput">{{ $t('domains.domainDialog.powerdnsApiKey') }}</label>
<MaskedInput id="powerdnsApiKeyInput" v-model="dnsConfig.apiKey" required />
</FormGroup>
```
### `dashboard/public/translation/en.json`
* **Add Translations:** Add the English translation strings for the new fields under the `domains.domainDialog` object:
```json
"powerdnsApiUrl": "PowerDNS API URL (e.g., https://ns1.example.com:8081)",
"powerdnsApiKey": "API Key",
```
## 2. Backend Modifications
The backend needs a new driver file that implements the standard Cloudron DNS provider interface.
### `src/dns.js`
* **Import the Driver:** Add `import dnsPowerdns from './dns/powerdns.js';` at the top with the other imports.
* **Register the Provider:** Add `powerdns: dnsPowerdns` to the `DNS_PROVIDERS` mapping object.
### `src/dns/powerdns.js` (New File)
Create a new file that implements the standard DNS interface (`src/dns/interface.js`).
* **Required Methods:** Export `removePrivateFields`, `injectPrivateFields`, `upsert`, `get`, `del`, `wait`, and `verifyDomainConfig`.
* **API Interactions:**
* Construct the base URL: `const baseUrl = domainConfig.apiUrl.replace(/\/$/, '');`
* Ensure queries and zone names have a trailing dot, as PowerDNS strictly requires absolute FQDNs (e.g., `example.com.`).
* Set the `X-API-Key` header using `domainConfig.apiKey` for all requests.
* **Operations:**
* `get`: Use `GET ${baseUrl}/api/v1/servers/localhost/zones/${zoneName}.` and extract records from the `rrsets` array matching the exact `fqdn` and `type`.
* `upsert`: Use `PATCH ${baseUrl}/api/v1/servers/localhost/zones/${zoneName}.`. Send an `rrsets` payload with `changetype: 'REPLACE'`. Ensure `TXT` values are quoted.
* `del`: Use `PATCH` with `changetype: 'DELETE'`.
* **verifyDomainConfig:** Perform a test `upsert` and `del` of an `A` record on the subdomain `cloudrontestdns` to ensure the API is reachable and the key has edit permissions.
## 3. Automated Testing (Local)
The new provider needs to be verified against the existing test suite using mock HTTP requests.
### `src/test/dns-providers-test.js`
* Add a new `describe('powerdns', ...)` block.
* Setup mock domain configuration with a dummy `apiUrl` and `apiKey`.
* Use `nock` to intercept requests to the dummy `apiUrl`.
* Write tests for:
* `upsert` non-existing and existing records.
* `get` records.
* `del` records.
* Ensure the nock endpoints expect the exact JSON structure and `rrsets` payload required by PowerDNS.
### Running the Tests
Run the specific test suite for DNS providers locally:
```bash
./run-tests src/test/dns-providers-test.js
```
*(This uses the fast-path to run mocha directly, skipping the full Cloudron test environment setup).*
## 4. Live Testing (Remote Cloudron Server)
To test the integration end-to-end, deploy the changes to a temporary Cloudron instance using the provided hotfix script.
**Important Considerations for Hotfixing:**
The `scripts/hotfix` tool builds a release tarball by executing `git archive HEAD`. **You must commit your changes locally before running the hotfix**, otherwise the script will fail or push the old codebase.
1. **Commit Changes:**
```bash
git add .
git commit -m "Implement PowerDNS provider"
```
2. **Run Hotfix Script:**
Execute the hotfix script from the repository root, pointing it to your test server.
```bash
./scripts/hotfix --cloudron <IP_OR_DOMAIN_OF_TEST_SERVER> --ssh-user root --ssh-key ~/.ssh/id_rsa --release 1.0.0-test
```
3. **Verify Deployment:**
* The script will install dependencies, compile the Vue dashboard, bundle the backend code, push it to the server, and restart the `box` service.
* Log into the Cloudron dashboard via your browser.
* Navigate to Domains -> Add Domain, select "PowerDNS".
* Enter a test domain, a valid PowerDNS API URL, and an API Key.
* Verify that Cloudron successfully configures the domain and creates the necessary initial DNS records.

103
box.js
View File

@@ -1,17 +1,19 @@
#!/usr/bin/env node
'use strict';
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 '@cloudron/safetydance';
import server from './src/server.js';
import directoryServer from './src/directoryserver.js';
import logger from './src/logger.js';
const constants = require('./src/constants.js'),
fs = require('node:fs'),
ldapServer = require('./src/ldapserver.js'),
net = require('node:net'),
oidcServer = require('./src/oidcserver.js'),
paths = require('./src/paths.js'),
proxyAuth = require('./src/proxyauth.js'),
safe = require('safetydance'),
server = require('./src/server.js'),
directoryServer = require('./src/directoryserver.js');
const { log } = logger('box');
let logFd;
@@ -36,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);
@@ -54,50 +58,45 @@ async function startServers() {
if (conf.enabled) await directoryServer.start();
}
async function main() {
const [error] = await safe(startServers());
if (error) return exitSync({ error, code: 1, message: 'Error starting servers' });
const [error] = await safe(startServers());
if (error) exitSync({ error, code: 1, message: 'Error starting servers' });
// require this here so that logging handler is already setup
const debug = require('debug')('box:box');
process.on('SIGHUP', async function () {
log('Received SIGHUP. Re-reading configs.');
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
process.on('SIGHUP', async function () {
debug('Received SIGHUP. Re-reading configs.');
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
process.on('SIGINT', async function () {
log('Received SIGINT. Shutting down.');
process.on('SIGINT', async function () {
debug('Received SIGINT. Shutting down.');
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
setTimeout(() => {
log('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
setTimeout(() => {
debug('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
process.on('SIGTERM', async function () {
log('Received SIGTERM. Shutting down.');
process.on('SIGTERM', async function () {
debug('Received SIGTERM. Shutting down.');
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
setTimeout(() => {
log('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
setTimeout(() => {
debug('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.' }));
}
main();
process.on('uncaughtException', (uncaughtError) => exitSync({ error: uncaughtError, code: 1, message: 'From uncaughtException handler.' }));

View File

@@ -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>

View File

@@ -2,6 +2,11 @@
set -eu
# Check if the API origin is set, if not prompt the user to enter it
if [[ -z "${DASHBOARD_DEVELOPMENT_ORIGIN:-}" ]]; then
read -p "Enter the API origin (e.g. http://localhost:3000): " DASHBOARD_DEVELOPMENT_ORIGIN
fi
echo "=> Set API origin"
export VITE_API_ORIGIN="${DASHBOARD_DEVELOPMENT_ORIGIN}"

View File

@@ -14,6 +14,8 @@ export default [
"prefer-const": "error",
"vue/no-reserved-component-names": "off",
"vue/multi-word-component-names": "off",
"vue/no-undef-components": "error",
'vue/no-root-v-if': "error",
}
}
];

View File

@@ -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>

View 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>

View 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>

View 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>

View File

@@ -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>

View File

@@ -9,6 +9,8 @@
name: name,
note: note,
submitUrl: submitUrl,
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
passkeyLoginUrl: passkeyLoginUrl,
footer: footer,
language: language
}) %>;

File diff suppressed because it is too large Load Diff

View File

@@ -7,26 +7,27 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.6.4",
"@simplewebauthn/browser": "^13.3.0",
"@cloudron/pankow": "^4.1.10",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"@vitejs/plugin-vue": "^6.0.2",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.3",
"@fortawesome/fontawesome-free": "^7.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"anser": "^2.3.5",
"async": "^3.2.6",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.39.1",
"eslint-plugin-vue": "^10.6.2",
"marked": "^17.0.1",
"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.2.7",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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.",
@@ -1067,11 +1062,6 @@
}
},
"uninstall": {
"startStop": {
"description": "Apps kan stoppes for at spare på serverressourcerne i stedet for at blive afinstalleret. Fremtidige app-backups vil ikke omfatte app-ændringer mellem nu og den seneste app-backup. Derfor anbefales det at udløse en sikkerhedskopi, før appen stoppes.",
"startAction": "Start app",
"stopAction": "Stop App"
},
"uninstall": {
"title": "Afinstaller",
"description": "Dette vil afinstallere appen med det samme og fjerne alle dens data. Der vil ikke være adgang til webstedet.",

View File

@@ -42,10 +42,12 @@
"next": "Weiter",
"configure": "Konfigurieren",
"restart": "Neu starten",
"reset": "Zurücksetzen"
"reset": "Zurücksetzen",
"loadMore": "Mehr laden"
},
"table": {
"version": "Version"
"version": "Version",
"created": "Erstellt"
},
"actions": "Aktionen",
"rebootDialog": {
@@ -66,6 +68,9 @@
"loadingPlaceholder": "Laden",
"platform": {
"startupFailed": "Plattform-Start fehlgeschlagen"
},
"sidebar": {
"collapseAction": "Seitenleiste einklappen"
}
},
"network": {
@@ -129,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": {
@@ -149,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.",
@@ -365,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.",
@@ -401,7 +400,7 @@
"description": "Persönlichen Zugriffstoken zur Authentifizierung gegenüber der <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a> verwenden.",
"name": "Name",
"title": "API-Tokens",
"lastUsed": "Zuletzt Verwendet",
"lastUsed": "Zuletzt verwendet",
"neverUsed": "nie",
"scope": "Bereich",
"readonly": "Schreibgeschützt",
@@ -630,7 +629,12 @@
"settingsDialog": {
"description": "Eine E-Mail wird für die ausgewählten Ereignisse an Ihre primäre E-Mail-Adresse gesendet."
},
"allCaughtUp": "Alles erledigt"
"allCaughtUp": "Alles erledigt",
"title": "Benachrichtigungen",
"showAll": "Alle",
"showUnread": "Ungelesen",
"markUnread": "Als ungelesen markieren",
"markRead": "Als gelesen markieren"
},
"system": {
"diskUsage": {
@@ -741,12 +745,12 @@
"enable": "Automatische Datensicherung aktivieren"
},
"backupDetails": {
"version": "Version",
"date": "Datum",
"id": "Id",
"version": "Paketversion",
"date": "Erstellt",
"id": "Datensicherungs Id",
"title": "Backup-Details",
"size": "Größe",
"duration": "Dauer"
"duration": "Datensicherungsdauer"
},
"listing": {
"backupNow": "Backup jetzt erstellen",
@@ -758,7 +762,8 @@
"contents": "Inhalt",
"noBackups": "Keine Datensicherungen",
"title": "Datensicherungen",
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten"
"tooltipPreservedBackup": "Dieses Backup bleibt erhalten",
"description": "System-Datensicherungen enthalten die Cloudron-Konfiguration und Metadaten der App-Installation. Sie können dazu verwendet werden, die gesamte Cloudron-Installation auf einen anderen Server zu <a href=\"{{restoreLink}}\" target=\"_blank\">wiederherzustellen</a> oder zu <a href=\"{{migrateLink}}\" target=\"_blank\">migrieren</a>."
},
"schedule": {
"retentionPolicy": "Aufbewahrungsrichtlinie",
@@ -1181,7 +1186,7 @@
"title": "Ungültiger oder abgelaufener Einladungslink"
},
"success": {
"title": "Ihr Konto ist bereit",
"title": "Konto ist bereit",
"openDashboardAction": "Dashboard öffnen"
},
"fullName": "Vollständiger Name",
@@ -1193,8 +1198,9 @@
"description": "Konto einrichten",
"noUsername": {
"title": "Das Konto kann nicht eingerichtet werden.",
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden."
}
"description": "Ein Konto kann nicht ohne einen Benutzernamen eingerichtet werden. Kontaktiere den Administrator."
},
"welcome": "Willkommen"
},
"app": {
"accessControl": {
@@ -1209,10 +1215,10 @@
"visibleForSelected": "Nur für die folgenden User und Gruppen sichtbar",
"descriptionSftp": "Steuert auch den SFTP-Zugriff.",
"visibleForAllUsers": "Sichtbar für alle User auf dieser Cloudron-Instanz",
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann."
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann"
},
"operators": {
"description": "Die Betreiber können diese Anwendung konfigurieren und pflegen.",
"description": "Wer kann Anwendung konfigurieren und pflegen",
"title": "Administratoren"
},
"dashboardVisibility": {
@@ -1232,19 +1238,37 @@
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht"
},
"devices": {
"label": "Geräte"
"label": "Geräte",
"description": "Durch Kommas getrennte Liste der an die App angeschlossenen Geräte"
}
},
"security": {
"csp": {
"saveAction": "Speichern",
"description": "Das Setzen dieser Option überschreibt alle CSP-Header, die von der Anwendung selbst gesendet werden.",
"title": "Content-Security-Policy"
"description": "Überschreibe alle CSP-Header, die von der App definiert sind.",
"title": "Content-Security-Policy",
"insertCommonCsp": "Gängige CSP einfügen",
"commonPattern": {
"allowEmbedding": "Einbetten zulassen",
"sameOriginEmbedding": "Einbetten zulassen (nur Unterdomänen)",
"allowCdnAssets": "CDN-Ressourcen zulassen",
"reportOnly": "CSP-Verstöße melden",
"strictBaseline": "Strikte Baseline"
}
},
"robots": {
"title": "robots.txt"
"title": "robots.txt",
"description": "Standardmäßig können Bots diese App indexieren",
"commonPattern": {
"allowAll": "Alle zulassen (Standard)",
"disallowAll": "Alle verweigern",
"disallowCommonBots": "Gängige Bots blockieren",
"disallowAdminPaths": "Admin-Pfade sperren",
"disallowApiPaths": "API-Pfade sperren"
},
"insertCommonRobotsTxt": "Gängige robots.txt einfügen"
},
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
"hstsPreload": "HSTS-Preload aktivieren (einschließlich Unterdomänen)"
},
"email": {
"from": {
@@ -1252,17 +1276,20 @@
"mailboxPlaceholder": "Postfachname",
"saveAction": "Speichern",
"disableDescription": "Die E-Mail Einstellungen werden nicht automatisch vorgenommen, dies muss in der App selbst gemacht werden.",
"enable": "Verwende Cloudron um E-Mails zu versenden",
"enableDescription": "Diese App verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}.",
"enable": "Verwende Cloudron, um E-Mails zu versenden",
"enableDescription": "Konfigurieren Sie die App so, dass E-Mail über die untenstehende Adresse gesendet wird und <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail</a> Einstellungen.",
"disable": "E-Mail Konfiguration nicht automatisch vornehmen",
"displayName": "Absendername"
},
"inbox": {
"title": "Eingehende E-Mail",
"enable": "Benutze Cloudron Mail um E-Mails zu empfangen",
"enableDescription": "Die App ist so konfiguriert, dass sie E-Mails über die unten stehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail für {{ domain }} auf diesem Server gehostet wird.",
"enableDescription": "Konfigurieren Sie die App so, dass sie E-Mail über die untenstehende Adresse empfängt. Wählen Sie diese Option, wenn die E-Mail von {{ domain }} auf diesem Server gehostet wird.",
"disableDescription": "Die Einstellungen für den Posteingang in der App sind nicht betroffen. Sie können sie innerhalb der App konfigurieren. Wählen Sie dies, wenn die E-Mail der Domain nicht auf Cloudron gehostet wird.",
"disable": "Posteingang nicht konfigurieren"
},
"configuration": {
"title": "Ausgehende E-Mails"
}
},
"repair": {
@@ -1285,13 +1312,8 @@
},
"repairTabTitle": "Reparatur",
"uninstall": {
"startStop": {
"startAction": "Starten",
"stopAction": "Stoppen",
"description": "Anwendungen können angehalten werden, um Server-Ressourcen zu schonen, anstatt sie zu deinstallieren. Zukünftige Anwendungs-Backups werden keine Änderungen von Anwendungen zwischen jetzt und dem letzten Anwendungs-Backup enthalten. Aus diesem Grund wird empfohlen, vor dem Stoppen der Anwendung ein Backup auszulösen."
},
"uninstall": {
"description": "Dies wird die Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
"description": "Anwendung deinstallieren und alle zugehörigen Daten löschen. Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
"title": "Deinstallieren",
"uninstallAction": "Deinstallieren"
}
@@ -1310,11 +1332,11 @@
"appId": "ID der Anwendung",
"lastUpdated": "Letzte Aktualisierung",
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
"packageVersion": "Paket-Version",
"packageVersion": "Paket",
"installedAt": "Installationszeitpunkt"
},
"auto": {
"description": "App-Updates werden regelmäßig gemäß dem Aktualisierungszeitplan angewendet.",
"description": "App-Updates werden regelmäßig gemäß dem <a href=\"/#/system-update\">Aktualisierungszeitplan</a> angewendet",
"title": "Automatische Updates"
},
"updates": {
@@ -1325,7 +1347,7 @@
"backups": {
"title": "Backups",
"downloadConfigTooltip": "Konfiguration herunterladen",
"description": "Backups erstellen komplette Abbilder der Anwendung. Ein Anwendungsbackup kann zum Wiederherstellen oder Klonen dieser Anwendung verwendet werden.",
"description": "Vollständige Datensicherung der App erstellen",
"importAction": "Backup importieren",
"cloneTooltip": "Duplizieren",
"restoreTooltip": "Wiederherstellen",
@@ -1335,11 +1357,11 @@
},
"auto": {
"title": "Automatische Backups",
"description": "Die App wird periodisch gemäß dem Datensicherungszeitplan gesichert."
"description": "Regelmäßig eine Datensicherung der App auf die konfigurierten <a href=\"/#/backup-sites\">Datensicherungsstandorte</a> erstellen"
},
"import": {
"title": "Von einem externen Backup importieren",
"description": "Dies hier verwenden, um eine Anwendung von einer anderen Cloudron-Instanz zu migrieren. Die zu migrierende Anwendung muss die gleiche Paket-Version und Zugriffsrechte aufweisen wie diese hier."
"title": "Importieren",
"description": "App aus einer externen Datensicherung importieren"
}
},
"appInfo": {
@@ -1352,14 +1374,14 @@
"storage": {
"appdata": {
"title": "Datenverzeichnis",
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
"description": "Verschiebe die App-Daten auf einen <a href=\"/#/volumes\">Datenträger</a>. Alle hier befindlichen Daten sind in der Datensicherung der App enthalten.",
"moveAction": "Daten verschieben",
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
},
"mounts": {
"title": "Datenträger Mounts",
"volume": "Datenträger",
"noMounts": "Es sind keine Datenträger gemounted.",
"noMounts": "Kein Datenträger eingehängt",
"addMountAction": "Einen Datenträger mount hinzufügen",
"saveAction": "Speichern",
"permissions": {
@@ -1370,15 +1392,15 @@
}
},
"uninstallDialog": {
"title": "{{ app }} deinstallieren",
"description": "Dies wird {{ app }} sofort deinstallieren und alle Daten löschen.",
"title": "Anwendung deinstallieren",
"description": "Anwendung {{ app }} deinstallieren und alle Daten löschen.",
"uninstallAction": "Deinstallieren"
},
"restoreDialog": {
"warning": "Alle Daten, die zwischen jetzt und der letzten bekannten Sicherung erzeugt wurden, gehen unwiderruflich verloren. Es wird empfohlen, ein Backup der aktuellen Daten zu erstellen, bevor eine Wiederherstellung versucht wird.",
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
"restoreAction": "Wiederherstellen",
"title": "{{ app }} wiederherstellen",
"description": "Hierdurch wird diese Anwendung mit den Daten vom {{ creationTime }} wiederhergestellt.",
"title": "App wiederherstellen",
"description": "Wiederherstellen von \"{{ fqdn }}\" aus der Datensicherung, die am {{ creationTime }} erstellt wurde?",
"cloneAction": "Klonen",
"cloneActionOverwrite": "DNS klonen und DNS überschreiben"
},
@@ -1392,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.",
@@ -1405,8 +1426,8 @@
"updateAction": "Aktualisieren"
},
"cloneDialog": {
"title": "{{ app }} klonen",
"description": "Backup vom <b>{{ creationTime }}</b> und der Version <b>v{{ packageVersion }}</b> verwenden",
"title": "App klonen",
"description": "Klonen mit der Datensicherung vom <b>{{ creationTime }}</b> (Version <b>{{ packageVersion }}</b>).",
"location": "Standort"
},
"graphs": {
@@ -1427,8 +1448,10 @@
"title": "Backup importieren",
"uploadAction": "Datensicherungskonfiguration hochladen",
"importAction": "Importieren",
"remotePath": "Backup-Pfad",
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder"
"remotePath": "Datensicherungs-Pfad",
"provideBackupInfo": "Geben Sie die Datensicherungsinformationen an, von denen wiederhergestellt werden soll, oder",
"warning": "Alle Daten, die seit der letzten Datensicherung erstellt wurden, gehen dauerhaft verloren. Es wird empfohlen, vor dem Import eine neue Datensicherung zu erstellen.",
"versionMustMatchInfo": "Die Datensicherung muss mit derselben Paketversion und denselben Zugriffssteuerungseinstellungen wie diese App erstellt worden sein."
},
"terminalActionTooltip": "Terminal",
"filemanagerActionTooltip": "Dateimanager",
@@ -1460,8 +1483,8 @@
},
"title": "Crontab",
"saveAction": "Speichern",
"addCommonPattern": "Häufige Muster hinzufügen",
"description": "Benutzerdefinierte app-spezifische Cronjobs können hier hinzugefügt werden. Beachten Sie, dass Cronjobs, die für das Funktionieren der App erforderlich sind, bereits in das App-Paket integriert sind und hier nicht konfiguriert werden müssen."
"addCommonPattern": "Gängiges Muster einfügen",
"description": "Für den Betrieb der App erforderliche Cron-Jobs sind bereits im App-Paket integriert. Fügen Sie hier nur zusätzliche Jobs hinzu, die speziell zu Ihrem Setup passen."
},
"sftpInfoAction": "SFTP Zugang",
"cronTabTitle": "Cron",
@@ -1479,7 +1502,7 @@
},
"redis": {
"title": "Redis Konfiguration",
"info": "Wenn aktiviert, verwendet die App den integrierten Redis-Dienst. Wenn deaktiviert, bleiben die Redis-Einstellungen der App unberührt."
"info": "Integrierten Redis-Dienst verwenden. Wenn er deaktiviert ist, bleiben die App-Redis-Einstellungen unverändert."
},
"infoTabTitle": "Info",
"info": {
@@ -1489,19 +1512,19 @@
},
"turn": {
"title": "TURN Einstellungen",
"info": "Aktivieren Sie diese Option, um die App so zu konfigurieren, dass der integrierte TURN-Server verwendet wird. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
"info": "Verwenden Sie den eingebauten TURN-Server. Wenn deaktiviert, bleiben die TURN-Einstellungen der App unverändert."
},
"servicesTabTitle": "Dienste",
"archive": {
"title": "Archiv",
"action": "Archiv",
"noBackup": "Diese App hat keine Datensicherung. Archivierung benötigt eine aktuelle Datensicherung.",
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/backups\">Archiv</a> hinzugefügt. Die App wird deinstalliert, aber kann im Datensicherungsbereich wiederhergestellt werden. Die anderen Datensicherungen werden basierend der Aufbewahrungseinstellungen bereinigt.",
"description": "Die letzte Datensicherung der App wird dem <a href=\"/#/app-archive\">Archiv</a> hinzugefügt und die App deinstalliert.",
"latestBackupInfo": "Die letzte Datensicherung wurde am {{ date }} erstellt."
},
"archiveDialog": {
"description": "Dies deinstalliert die App und legt die letzte Datensicherung, erstellt am {{ date }} ins Archiv.",
"title": "Archiviere {{ app }}"
"description": "App deinstallieren und letzte Datensicherung vom {{ date }} ins Archiv legen?",
"title": "App archivieren"
},
"configureTooltip": "Konfigurieren",
"updateAvailableTooltip": "Aktualisierung verfügbar",
@@ -1516,13 +1539,15 @@
"clear": "Anzeige löschen"
},
"volumes": {
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Apps gemeinsam genutzt werden können.",
"removeVolumeDialog": {
"removeAction": "Entfernen"
"removeAction": "Entfernen",
"title": "Datenträger entfernen",
"description": "Datenträger \"{{ volumeName }}\" entfernen?"
},
"addVolumeDialog": {
"title": "Datenträger hinzufügen",
"server": "Server IP oder Hostname",
"server": "Server IP / Hostname",
"remoteDirectory": "Remote-Verzeichnis",
"username": "Username",
"password": "Passwort",
@@ -1538,7 +1563,7 @@
"localDirectory": "Lokales Verzeichnis",
"remountActionTooltip": "Neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
"title": "Datenträger konfigurieren"
},
"emptyPlaceholder": "Keine Datenträger"
},
@@ -1551,7 +1576,7 @@
},
"storage": {
"mounts": {
"description": "Eingehängte Datenträger können unter <code>/media/(Datenträgername)</code> zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
"description": "Eingehängte Datenträger können unter \"/media/(Datenträgername)\" zugegriffen werden. Eingehängte Daten werden nicht in der Datensicherung der App erfasst."
}
},
"oidc": {
@@ -1560,19 +1585,20 @@
"createAction": "Hinzufügen"
},
"client": {
"name": "Name",
"name": "Client Name",
"id": "Client ID",
"signingAlgorithm": "Signatur Algorithmus",
"loginRedirectUri": "Login Callback URLs (mit Komma getrennt)",
"secret": "Client Geheimnis"
"loginRedirectUri": "Login Callback URLs",
"secret": "Client Geheimnis",
"loginRedirectUriPlaceholder": "Durch Kommas getrennte URLs"
},
"description": "OpenID kann von externen Anwendungen für Single Sign-On verwendet werden.",
"editClientDialog": {
"title": "Client {{ client }} bearbeiten"
"title": "Client bearbeiten"
},
"deleteClientDialog": {
"title": "Wirklich Client {{ client }} löschen?",
"description": "Wenn dies gelöscht wird, werden alle Tokens dieses OpenID Clients, ungültig gemacht. Damit werden alle externen OpenID Apps, die diese Clientendetails nutzen, getrennt."
"title": "Client löschen",
"description": "Nach der Löschung werden alle von diesem Client ausgestellten Zugriffstoken ungültig. Apps, die ihn verwenden, können sich nicht mehr authentifizieren.<br/><br/>Client '{{ clientName }}' löschen?"
},
"env": {
"discoveryUrl": "Discovery URL"
@@ -1580,6 +1606,10 @@
"clients": {
"title": "OpenID-Clients",
"empty": "Keine OpenID-Clients"
},
"clientCredentials": {
"title": "Client Zugangsdaten",
"description": "Zugangsdaten des Clients \"{{ clientName }}\" kopieren"
}
},
"userdirectory": {
@@ -1593,22 +1623,25 @@
"archives": {
"listing": {
"placeholder": "Keine archivierten Apps"
}
},
"description": "Archivierte Apps bewahren die neueste Datensicherung auf, wenn sie archiviert wurde. Diese Datensicherungen werden dauerhaft aufbewahrt und können wiederhergestellt werden."
},
"backup": {
"target": {
"label": "Datensicherungsstandort",
"size": "Größe"
"label": "Standort",
"size": "Größe",
"fileCount": "Dateien"
},
"sites": {
"title": "Datensicherungsstandorte",
"emptyPlaceholder": "Keine Datensicherungsstandorte",
"lastRun": "Letzter Lauf"
"lastRun": "Letzter Lauf",
"description": "Datensicherungsstandorte geben an, wo System- und App-Datensicherungen gespeichert werden. App-Datensicherungen können einzeln wiederhergestellt werden."
},
"site": {
"removeDialog": {
"description": "Dies entfernt auch alle Datensicherungseinträge, die mit diesem Standort verbunden sind.",
"title": "Wollen Sie diesen Datensicherungsstandort wirklich entfernen?"
"description": "Beim Entfernen eines Datensicherungsstandorts werden dessen zugehörige Datensicherungseinträge von Cloudron gelöscht. Auf dem entfernten Zielort gespeicherte Datensicherungsdateien werden nicht gelöscht.<br/></br>Datensicherungsstandort '{{ name }}' entfernen?",
"title": "Datensicherungsstandort entfernen"
}
}
},
@@ -1617,17 +1650,21 @@
"provider": "Anbieter",
"username": "Username",
"title": "Docker-Registries",
"description": "Cloudron kann benutzerdefinierte Apps aus einer privaten Docker-Registry ziehen und installieren.",
"description": "Zugriff auf private Docker-Registries konfigurieren, um benutzerdefinierte Apps zu installieren.",
"removeDialog": {
"title": "{{ serverAddress }} löschen"
"title": "Docker-Registry entfernen"
},
"email": "E-Mail",
"passwordToken": "Passwort/Token",
"emptyPlaceholder": "Keine Docker-Registries"
"emptyPlaceholder": "Keine Docker-Registries",
"dialog": {
"addTitle": "Docker-Registry hinzufügen",
"editTitle": "Docker-Registry bearbeiten"
}
},
"dockerRegistres": {
"removeDialog": {
"description": "Möchten Sie diese Registry wirklich entfernen?"
"description": "Docker-Registry \"{{ serverAddress }}\" entfernen?"
}
},
"dashboard": {

View File

@@ -47,7 +47,10 @@
"next": "Next",
"configure": "Configure",
"restart": "Restart",
"reset": "Reset"
"reset": "Reset",
"loadMore": "Load more",
"setup": "Set up",
"disable": "Disable"
},
"rebootDialog": {
"title": "Reboot Server",
@@ -104,6 +107,9 @@
"appNotFoundDialog": {
"title": "App not found",
"description": "There is no such app <b>{{ appId }}</b> with version <b>{{ version }}</b>."
},
"action": {
"addCustomApp": "Add custom app"
}
},
"users": {
@@ -287,14 +293,19 @@
"authenticatorAppDescription": "Use Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) or a similar TOTP app to scan the secret.",
"token": "Token",
"enable": "Enable",
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue."
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue.",
"passkeyOption": "Passkey",
"totpOption": "TOTP",
"registerPasskey": "Set up passkey",
"passkeyDescription": "The browser will prompt you to create a passkey using your device's biometrics or a password manager."
},
"appPasswords": {
"title": "App Passwords",
"app": "App",
"name": "Name",
"noPasswordsPlaceholder": "No app passwords",
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here."
"description": "App passwords are a security measure to protect your Cloudron user account. If you need to access a Cloudron app from an untrusted mobile app or client, you can log in with your username and the alternate password generated here.",
"expires": "Expires"
},
"apiTokens": {
"title": "API Tokens",
@@ -327,7 +338,8 @@
"name": "Password name",
"app": "App",
"description": "Use the following password to authenticate against the app:",
"copyNow": "Please copy the password now. It won't be shown again for security purposes."
"copyNow": "Please copy the password now. It won't be shown again for security purposes.",
"expiresAt": "Expiry date"
},
"createApiToken": {
"title": "Add API Token",
@@ -338,8 +350,6 @@
"allowedIpRanges": "Allowed IP range(s)"
},
"changePasswordAction": "Change password",
"disable2FAAction": "Disable 2FA",
"enable2FAAction": "Enable 2FA",
"passwordResetNotification": {
"body": "Email sent to {{ email }}"
},
@@ -350,6 +360,26 @@
"removeAppPassword": {
"title": "Remove App Password",
"description": "Remove app password \"{{ name }}\" ?"
},
"twoFactorAuth": {
"title": "Two-factor authentication",
"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": {
@@ -381,7 +411,10 @@
"date": "Created",
"version": "Package version",
"size": "Size",
"duration": "Backup duration"
"duration": "Backup duration",
"lastIntegrityCheck": "Last integrity check",
"integrityNever": "never",
"integrityInProgress": "In progress"
},
"configureBackupSchedule": {
"title": "Configure Backup Schedule & Retention",
@@ -499,7 +532,9 @@
"title": "Configure Backup Content"
},
"useFileAndFileNameEncryption": "File and filename encryption used",
"useFileEncryption": "File encryption used"
"useFileEncryption": "File encryption used",
"checkIntegrity": "Check integrity",
"stopIntegrity": "Stop integrity check"
},
"branding": {
"title": "Branding",
@@ -686,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 doesnt overlap with backup schedules."
},
"updateDialog": {
@@ -716,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": {
@@ -773,7 +815,9 @@
"changeDashboardDomain": {
"title": "Dashboard Domain",
"description": "Change the dashboard to the “my” subdomain of the selected domain",
"changeAction": "Change domain"
"changeAction": "Change domain",
"confirmMessage": "This will invalidate all passkeys for users.",
"confirmTitle": "Really change dashboard domain?"
},
"domainDialog": {
"addTitle": "Add Domain",
@@ -781,6 +825,8 @@
"domain": "Domain",
"provider": "DNS provider",
"route53AccessKeyId": "Access key ID",
"powerdnsApiUrl": "PowerDNS API URL (e.g., https://ns1.example.com:8081)",
"powerdnsApiKey": "API Key",
"route53SecretAccessKey": "Secret access key",
"gcdnsServiceAccountKey": "Service account key",
"digitalOceanToken": "DigitalOcean token",
@@ -831,7 +877,9 @@
"inwxUsername": "INWX username",
"inwxPassword": "INWX password",
"customNameservers": "Domain uses custom (vanity) nameservers",
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain."
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain.",
"carddavLocation": "CardDAV server location",
"caldavLocation": "CalDAV server location"
},
"removeDialog": {
"title": "Remove Domain",
@@ -865,12 +913,19 @@
"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."
},
"allCaughtUp": "All caught up"
"allCaughtUp": "All caught up",
"title": "Notifications",
"showAll": "All",
"showUnread": "Unread",
"markUnread": "Mark as unread",
"markRead": "Mark as read"
},
"logs": {
"title": "Logs",
@@ -894,11 +949,11 @@
"reallyDelete": "Really delete?"
},
"newDirectoryDialog": {
"title": "New Folder Name",
"title": "New folder",
"create": "Create"
},
"newFileDialog": {
"title": "New Filename",
"title": "New filename",
"create": "Create"
},
"renameDialog": {
@@ -916,16 +971,17 @@
"restartApp": "Restart App",
"uploadFolder": "Upload folder",
"openTerminal": "Open terminal",
"openLogs": "Open logs"
"openLogs": "Open logs",
"refresh": "Refresh"
},
"extractionInProgress": "Extraction in progress",
"pasteInProgress": "Pasting in progress",
"deleteInProgress": "Deletion in progress",
"chownDialog": {
"title": "Change ownership",
"title": "Change owner",
"newOwner": "New owner",
"change": "Change Owner",
"recursiveCheckbox": "Change ownership recursively"
"change": "Change owner",
"recursiveCheckbox": "Change owner recursively"
},
"uploadingDialog": {
"title": "Uploading files ({{ countDone }}/{{ count }})",
@@ -1176,7 +1232,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": {
@@ -1306,14 +1362,15 @@
"packageVersion": "Package version",
"lastUpdated": "Last updated",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"installedAt": "Installed"
"installedAt": "Installed",
"packager": "Packager"
},
"auto": {
"description": "App updates are applied periodically based on the <a href=\"/#/system-update\">update schedule</a>",
"title": "Automatic updates"
},
"updates": {
"description": "Cloudron automatically checks the App Store for updates. You can also check manually."
"description": "Cloudron automatically checks for app updates. You can also check manually."
}
},
"backups": {
@@ -1356,11 +1413,6 @@
}
},
"uninstall": {
"startStop": {
"description": "Apps can be stopped to conserve server resources instead of uninstalling. Future app backups will not include any app changes between now and the most recent app backup. For this reason, it is recommended to trigger a backup before stopping the app.",
"startAction": "Start",
"stopAction": "Stop"
},
"uninstall": {
"title": "Uninstall",
"description": "Uninstall the app and delete its data. Backups are cleaned up according to the backup policy.",
@@ -1472,6 +1524,16 @@
"forumAction": "Forum",
"appLink": {
"title": "External Link"
},
"start": {
"title": "Start",
"description": "Start the app to make it available again.",
"action": "Start"
},
"stop": {
"action": "Stop",
"title": "Stop",
"description": "Stop the app to conserve resources. Back up before stopping to preserve recent changes."
}
},
"login": {
@@ -1482,7 +1544,10 @@
"resetPasswordAction": "Reset password",
"errorIncorrect2FAToken": "2FA token is invalid",
"errorInternal": "Internal error, try again later",
"loginAction": "Log in"
"loginAction": "Log in",
"usePasskeyAction": "Use passkey",
"errorPasskeyFailed": "Failed to login with passkey",
"passkeyAction": "Log in with a passkey"
},
"passwordReset": {
"title": "Password reset",
@@ -1571,7 +1636,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",
@@ -1639,7 +1705,8 @@
"title": "Backup Sites",
"emptyPlaceholder": "No backup sites",
"lastRun": "Last run",
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually."
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually.",
"noAutomaticUpdateBackupWarning": "No backup site is configured to store backups for automatic updates. Enable \"Store automatic-update backups here\" on at least one backup site to allow automatic updates."
},
"site": {
"removeDialog": {
@@ -1678,5 +1745,9 @@
},
"server": {
"title": "Server"
},
"communityapp": {
"installwarning": "Community apps are not reviewed by Cloudron. Only install apps from trusted developers. Third-party code can compromise your system.",
"unstablewarning": "This app is marked as unstable by its developer."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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": {
@@ -714,11 +927,6 @@
"description": "Cette action entraînera la désinstallation immédiate de l'application et la suppression de l'ensemble de ses données. Le site sera inaccessible.",
"uninstallAction": "Désinstaller",
"title": "Désinstaller"
},
"startStop": {
"description": "Pour économiser les ressources du serveur, vous pouvez mettre en pause les applications au lieu de les désinstaller. Les futures sauvegardes d'applications ne comprendront pas les modifications apportées aux applications entre aujourd'hui et la dernière sauvegarde. Pour cette raison, il est recommandé de lancer une sauvegarde avant de mettre une application en pause.",
"stopAction": "Arrêter l'application",
"startAction": "Démarrer l'application"
}
},
"backups": {
@@ -738,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": {
@@ -747,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 }}"
@@ -762,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": {
@@ -831,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": {
@@ -846,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",
@@ -873,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": {
@@ -886,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",
@@ -894,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)",
@@ -905,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",
@@ -921,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 }}",
@@ -947,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",
@@ -982,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 }}"
@@ -994,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": {
@@ -1050,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",
@@ -1068,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": {
@@ -1076,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 %>]",
@@ -1125,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?",
@@ -1219,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"
},
@@ -1245,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": {
@@ -1284,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",
@@ -1293,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 %>,",
@@ -1313,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": {
@@ -1329,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."
}
}

View File

@@ -30,7 +30,8 @@
"edit": "Edit"
},
"table": {
"version": "Versi"
"version": "Versi",
"created": "Dibuat"
},
"logout": "Keluar",
"action": {
@@ -42,7 +43,10 @@
"configure": "Konfigurasi",
"restart": "Mulai ulang",
"reset": "Atur Ulang",
"logs": "Log"
"logs": "Log",
"loadMore": "Muat lebih banyak",
"setup": "Siapkan",
"disable": "Nonaktifkan"
},
"searchPlaceholder": "Cari",
"actions": "Tindakan",
@@ -87,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."
@@ -103,7 +107,10 @@
},
"unstable": "Tidak stabil",
"title": "Toko Aplikasi",
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …"
"searchPlaceholder": "Cari alternatif seperti GitHub, Dropbox, Slack, Trello, …",
"action": {
"addCustomApp": "Tambahkan aplikasi kustom"
}
},
"users": {
"users": {
@@ -275,11 +282,12 @@
"app": "Aplikasi",
"title": "Kata sandi Aplikasi",
"noPasswordsPlaceholder": "Tidak ada kata sandi aplikasi",
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini."
"description": "Kata sandi aplikasi adalah langkah keamanan untuk melindungi akun pengguna Cloudron Anda. Jika Anda perlu mengakses aplikasi Cloudron dari aplikasi seluler atau klien yang tidak tepercaya, Anda dapat masuk dengan nama pengguna Anda dan kata sandi alternatif yang dihasilkan di sini.",
"expires": "Kadaluarsa"
},
"apiTokens": {
"name": "Nama",
"lastUsed": "Terakhir Digunakan",
"lastUsed": "Terakhir digunakan",
"title": "Token API",
"scope": "Cakupan",
"description": "Gunakan token akses pribadi ini untuk melakukan otentikasi dengan <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API Cloudron</a>.",
@@ -295,7 +303,11 @@
"token": "Token",
"enable": "Aktifkan",
"mandatorySetup": "2FA diperlukan untuk mengakses dasbor. Silakan selesaikan pengaturan untuk melanjutkan.",
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia."
"authenticatorAppDescription": "Gunakan Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) atau aplikasi TOTP serupa untuk memindai kode rahasia.",
"passkeyOption": "Passkey",
"totpOption": "TOTP",
"registerPasskey": "Siapkan passkey",
"passkeyDescription": "Browser akan meminta Anda untuk membuat passkey menggunakan biometrik perangkat Anda atau pengelola kata sandi."
},
"language": "Bahasa",
"loginTokens": {
@@ -313,10 +325,9 @@
"title": "Tambahkan Kata Sandi Aplikasi",
"name": "Nama kata sandi",
"description": "Gunakan kata sandi berikut untuk mengautentikasi terhadap aplikasi:",
"copyNow": "Silakan salin kata sandi sekarang. Kata sandi ini tidak akan ditampilkan lagi untuk alasan keamanan."
"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",
@@ -349,6 +360,26 @@
"removeAppPassword": {
"title": "Hapus Kata sandi Aplikasi",
"description": "Hapus kata sandi aplikasi \"{{ name }}\"?"
},
"twoFactorAuth": {
"title": "Autentikasi dua faktor",
"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": {
@@ -362,15 +393,19 @@
"tooltipDownloadBackupConfig": "Unduh konfigurasi",
"cleanupBackups": "Bersihkan cadangan",
"tooltipPreservedBackup": "Cadangan ini akan dipertahankan",
"title": "Pencadangan Sistem"
"title": "Pencadangan Sistem",
"description": "Cadangan sistem berisi konfigurasi Cloudron dan metadata instalasi aplikasi. Cadangan ini dapat digunakan untuk <a href=\"{{restoreLink}}\" target=\"_blank\">memulihkan</a> atau <a href=\"{{migrateLink}}\" target=\"_blank\">memigrasikan</a> seluruh instalasi Cloudron ke server lain."
},
"backupDetails": {
"duration": "Durasi",
"version": "Versi",
"duration": "Durasi cadangan",
"version": "Versi paket",
"title": "Detail Cadangan",
"id": "Id",
"date": "Tanggal",
"size": "Ukuran"
"id": "ID Cadangan",
"date": "Dibuat",
"size": "Ukuran",
"lastIntegrityCheck": "Pemeriksaan integritas terakhir",
"integrityNever": "tidak pernah",
"integrityInProgress": "Sedang diproses"
},
"configureBackupSchedule": {
"hours": "Jam",
@@ -497,7 +532,9 @@
"title": "Konfigurasi Konten Cadangan"
},
"useFileAndFileNameEncryption": "Enkripsi berkas dan nama berkas digunakan",
"useFileEncryption": "Enkripsi berkas digunakan"
"useFileEncryption": "Enkripsi berkas digunakan",
"checkIntegrity": "Periksa integritas",
"stopIntegrity": "Hentikan pemeriksaan integritas"
},
"branding": {
"logo": "Logo",
@@ -704,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": {
@@ -734,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": {
@@ -791,7 +835,9 @@
"changeDashboardDomain": {
"title": "Dasbor Domain",
"description": "Ubah dashboard ke subdomain 'my' pada domain yang dipilih",
"changeAction": "Ubah domain"
"changeAction": "Ubah domain",
"confirmMessage": "Ini akan membatalkan semua passkey untuk pengguna.",
"confirmTitle": "Apakah Anda benar-benar ingin mengubah domain dasbor?"
},
"domainDialog": {
"addTitle": "Tambahkan Domain",
@@ -849,7 +895,9 @@
"inwxUsername": "Nama pengguna INWX",
"inwxPassword": "Kata sandi INWX",
"customNameservers": "Domain menggunakan nameserver kustom (vanity)",
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan."
"zoneNamePlaceholder": "Opsional. Jika tidak disediakan, akan menggunakan domain utama sebagai bawaan.",
"carddavLocation": "Lokasi server CardDAV",
"caldavLocation": "Lokasi server CalDAV"
},
"removeDialog": {
"title": "Hapus Domain",
@@ -888,11 +936,11 @@
"reallyDelete": "Apakah Anda yakin ingin menghapus?"
},
"newDirectoryDialog": {
"title": "Nama Folder Baru",
"title": "Folder Baru",
"create": "Buat"
},
"newFileDialog": {
"title": "Nama berkas Baru",
"title": "Nama berkas baru",
"create": "Buat"
},
"renameDialog": {
@@ -910,16 +958,17 @@
"restartApp": "Mulai ulang Aplikasi",
"uploadFolder": "Unggah folder",
"openTerminal": "Buka terminal",
"openLogs": "Buka log"
"openLogs": "Buka log",
"refresh": "Segarkan"
},
"extractionInProgress": "Ekstraksi sedang berlangsung",
"pasteInProgress": "Penempelan sedang berlangsung",
"deleteInProgress": "Penghapusan sedang berlangsung",
"chownDialog": {
"title": "Ubah kepemilikan",
"title": "Ubah pemilik",
"newOwner": "Pemilik baru",
"change": "Ubah Pemilik",
"recursiveCheckbox": "Ubah kepemilikan secara rekursif"
"change": "Ubah pemilik",
"recursiveCheckbox": "Ubah pemilik secara rekursif"
},
"uploadingDialog": {
"title": "Mengunggah berkas ({{ countDone }}/{{ count }})",
@@ -1159,11 +1208,11 @@
"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": {
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi.",
"description": "Konfigurasikan siapa yang dapat masuk dan menggunakan aplikasi",
"descriptionSftp": "Pengaturan ini juga mengontrol akses SFTP.",
"dashboardVisibility": "Visibilitas Dasbor",
"visibleForAllUsers": "Terlihat oleh semua pengguna di Cloudron ini",
@@ -1177,7 +1226,7 @@
},
"operators": {
"title": "Operator",
"description": "Para operator dapat mengonfigurasi dan memelihara aplikasi ini."
"description": "Konfigurasikan siapa yang dapat memelihara aplikasi"
},
"dashboardVisibility": {
"description": "Konfigurasikan siapa yang dapat melihat aplikasi ini di dasbor."
@@ -1282,7 +1331,7 @@
"cron": {
"title": "Crontab",
"saveAction": "Simpan",
"addCommonPattern": "Tambahkan pola umum",
"addCommonPattern": "Masukkan pola umum",
"commonPattern": {
"everyMinute": "Setiap Menit",
"everyHour": "Setiap Jam",
@@ -1334,13 +1383,29 @@
"accessControlTabTitle": "Kontrol Akses",
"security": {
"csp": {
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi.",
"description": "Timpa semua header CSP yang ditentukan oleh aplikasi",
"title": "Kebijakan Keamanan Konten",
"saveAction": "Simpan"
"saveAction": "Simpan",
"insertCommonCsp": "Masukkan CSP umum",
"commonPattern": {
"allowEmbedding": "Izinkan penyematan",
"sameOriginEmbedding": "Izinkan penyematan (hanya subdomain)",
"allowCdnAssets": "Izinkan aset CDN",
"reportOnly": "Laporkan pelanggaran CSP",
"strictBaseline": "Baseline yang ketat"
}
},
"robots": {
"title": "Robots.txt",
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini."
"description": "Secara bawaan, bot dapat mengindeks aplikasi ini",
"commonPattern": {
"allowAll": "Izinkan semua (bawaan)",
"disallowAll": "Larang semua",
"disallowCommonBots": "Larang bot umum",
"disallowAdminPaths": "Larang akses jalur admin",
"disallowApiPaths": "Larang jalur API"
},
"insertCommonRobotsTxt": "Masukkan robots.txt umum"
},
"hstsPreload": "Aktifkan HSTS Preload (termasuk subdomain)"
},
@@ -1351,20 +1416,21 @@
"packageVersion": "Versi paket",
"lastUpdated": "Terakhir diperbarui",
"customAppUpdateInfo": "Pembaruan otomatis tidak tersedia untuk aplikasi khusus.",
"installedAt": "Terpasang"
"installedAt": "Terpasang",
"packager": "Pengemas"
},
"auto": {
"description": "Pembaruan aplikasi diterapkan secara berkala berdasarkan <a href=\"/#/system-update\">jadwal pembaruan</a>",
"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": {
"backups": {
"title": "Cadangan",
"description": "Buat snapshot lengkap dari aplikasi tersebut.",
"description": "Buat snapshot lengkap dari aplikasi tersebut",
"downloadConfigTooltip": "Unduh konfigurasi",
"cloneTooltip": "Klon",
"restoreTooltip": "Pulihkan",
@@ -1375,7 +1441,7 @@
},
"import": {
"title": "Impor",
"description": "Impor aplikasi dari cadangan eksternal."
"description": "Impor aplikasi dari cadangan eksternal"
},
"auto": {
"title": "Cadangan otomatis",
@@ -1401,11 +1467,6 @@
}
},
"uninstall": {
"startStop": {
"startAction": "Mulai",
"stopAction": "Berhenti",
"description": "Aplikasi dapat dihentikan untuk menghemat sumber daya server daripada menghapusnya. Cadangan aplikasi di masa mendatang tidak akan mencakup perubahan pada aplikasi antara sekarang dan cadangan aplikasi terbaru. Oleh karena itu, disarankan untuk memicu cadangan sebelum menghentikan aplikasi."
},
"uninstall": {
"title": "Hapus instalasi",
"description": "Hapus instalasi aplikasi dan hapus datanya. Cadangan dibersihkan sesuai dengan kebijakan pencadangan.",
@@ -1450,7 +1511,17 @@
"title": "Arsipkan Aplikasi",
"description": "Hapus aplikasi {{ app }} dan pindahkan cadangan terbarunya (dibuat pada {{ date }}) ke arsip aplikasi?"
},
"updateAvailableTooltip": "Pembaruan tersedia"
"updateAvailableTooltip": "Pembaruan tersedia",
"start": {
"title": "Mulai",
"description": "Mulai aplikasi untuk membuatnya tersedia kembali.",
"action": "Mulai"
},
"stop": {
"action": "Berhenti",
"title": "Berhenti",
"description": "Hentikan aplikasi untuk menghemat sumber daya. Cadangkan sebelum menghentikan untuk mempertahankan perubahan terakhir."
}
},
"setupAccount": {
"errorPassword": "Kata sandi harus setidaknya 8 karakter",
@@ -1573,7 +1644,8 @@
"archives": {
"listing": {
"placeholder": "Tidak ada aplikasi yang diarsipkan"
}
},
"description": "Aplikasi yang diarsipkan menyimpan cadangan terbaru saat aplikasi tersebut diarsipkan. Cadangan ini disimpan secara permanen dan dapat dipulihkan."
},
"backup": {
"target": {
@@ -1584,7 +1656,9 @@
"sites": {
"title": "Situs Cadangan",
"emptyPlaceholder": "Tidak ada situs cadangan",
"lastRun": "Terakhir dijalankan"
"lastRun": "Terakhir dijalankan",
"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": {
@@ -1616,12 +1690,19 @@
"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."
},
"allCaughtUp": "Semua sudah ditangani"
"allCaughtUp": "Semua sudah ditangani",
"title": "Notifikasi",
"showAll": "Semua",
"showUnread": "Belum dibaca",
"markUnread": "Tandai sebagai belum dibaca",
"markRead": "Tandai sebagai sudah dibaca"
},
"logs": {
"title": "Log",
@@ -1636,7 +1717,10 @@
"resetPasswordAction": "Atur ulang kata sandi",
"errorIncorrect2FAToken": "Token 2FA tidak valid",
"errorInternal": "Terjadi kesalahan internal, coba lagi nanti",
"loginAction": "Masuk"
"loginAction": "Masuk",
"usePasskeyAction": "Gunakan passkey",
"errorPasskeyFailed": "Gagal masuk dengan passkey",
"passkeyAction": "Masuk dengan passkey"
},
"passwordReset": {
"title": "Pengaturan ulang kata sandi",
@@ -1658,5 +1742,9 @@
"title": "Kata sandi telah diubah",
"openDashboardAction": "Buka dasbor"
}
},
"communityapp": {
"installwarning": "Aplikasi komunitas tidak ditinjau oleh Cloudron. Hanya instal aplikasi dari pengembang tepercaya. Kode pihak ketiga dapat membahayakan sistem Anda.",
"unstablewarning": "Aplikasi ini ditandai sebagai tidak stabil oleh pengembangnya."
}
}

View File

@@ -169,11 +169,6 @@
"uninstallAction": "Disinstalla",
"description": "Questo disinstallerà immediatamente l'app e rimuoverà tutti i suoi dati. Il sito sarà inaccessibile.",
"title": "Disinstalla"
},
"startStop": {
"stopAction": "Ferma App",
"startAction": "Avvia App",
"description": "Le app possono essere interrotte per risparmiare le risorse del server invece di disinstallarle. I backup futuri delle app non includeranno alcuna modifica dell'app da adesso fino al backup dell'app più recente. Per questo motivo, si consiglia di fare un backup prima di arrestare l'app."
}
},
"repair": {
@@ -565,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.",
@@ -787,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",

View File

@@ -46,7 +46,10 @@
"next": "Volgende",
"configure": "Configureer",
"restart": "Herstart",
"reset": "Reset"
"reset": "Reset",
"loadMore": "Laad meer",
"setup": "Instellen",
"disable": "Uitschakelen"
},
"rebootDialog": {
"title": "Herstart Server",
@@ -104,6 +107,9 @@
"appNotFoundDialog": {
"title": "App niet gevonden",
"description": "De app <b>{{ appId }}</b> met versie <b>{{ version }}</b> bestaat niet."
},
"action": {
"addCustomApp": "Aangepaste app toevoegen"
}
},
"users": {
@@ -287,14 +293,19 @@
"enable": "Inschakelen",
"title": "Schakel Twee-Factor (2FA) authenticatie in",
"authenticatorAppDescription": "Gebruik Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) of vergelijkbare Twee-Factor (2FA) authenticatie app om de QR-code te scannen.",
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan."
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan.",
"passkeyOption": "Passkey",
"totpOption": "TOTP",
"registerPasskey": "Instellen passkey",
"passkeyDescription": "De browser zal je vragen een passkey aan te maken met de biometrie van je apparaat of via een wachtwoordbeheerder."
},
"appPasswords": {
"app": "App",
"name": "Naam",
"noPasswordsPlaceholder": "Geen app-wachtwoorden",
"title": "App wachtwoorden",
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken."
"description": "App wachtwoorden zijn een veiligheidsmiddel om je Cloudronaccount te beschermen. Indien je toegang wilt tot een Cloudron-app met een niet-vertrouwde mobiele app of andere software, kun je inloggen met je gebruikersnaam en app wachtwoord die je hier kunt aanmaken.",
"expires": "Verloopt"
},
"apiTokens": {
"title": "API Tokens",
@@ -327,7 +338,8 @@
"app": "App",
"description": "Het volgende wachtwoord is gegenereerd voor de app:",
"name": "Beschrijving van het wachtwoord",
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond."
"copyNow": "Let op: kopieer het wachtwoord nu, vanwege veiligheidsredenen wordt het nooit meer getoond.",
"expiresAt": "Vervaldatum"
},
"createApiToken": {
"title": "API Token aanmaken",
@@ -338,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 }}"
},
@@ -350,6 +360,26 @@
"removeAppPassword": {
"title": "Verwijder app-wachtwoord",
"description": "Verwijder App-wachtwoord \"{{ name }}\"?"
},
"twoFactorAuth": {
"title": "Twee-Factor (2FA) authenticatie",
"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": {
@@ -372,7 +402,8 @@
"backupNow": "Backup maken",
"appCount": "{{ appCount }} App(s)",
"tooltipDownloadBackupConfig": "Download configuratie",
"tooltipPreservedBackup": "Deze backup blijft behouden"
"tooltipPreservedBackup": "Deze backup blijft behouden",
"description": "Systeembackups bevatten Cloudron-configuratie en metadata van app-installaties. Ze kunnen worden gebruikt om te <a href=\"{{restoreLink}}\" target=\"_blank\">herstellen</a> of te <a href=\"{{migrateLink}}\" target=\"_blank\">migreren</a> van de volledige Cloudron-installatie naar een andere server."
},
"backupDetails": {
"title": "Backup Details",
@@ -380,7 +411,10 @@
"date": "Aangemaakt",
"version": "Package versie",
"size": "Grootte",
"duration": "Backup duur"
"duration": "Backup duur",
"lastIntegrityCheck": "Laatste integriteitscontrole",
"integrityNever": "nooit",
"integrityInProgress": "In uitvoering"
},
"configureBackupSchedule": {
"title": "Configureer Backup Planning & Bewaartermijn",
@@ -498,7 +532,9 @@
"title": "Configureer Backup Inhoud"
},
"useFileAndFileNameEncryption": "Bestand en bestandsnaam encryptie gebruikt",
"useFileEncryption": "Bestand encryptie gebruikt"
"useFileEncryption": "Bestand encryptie gebruikt",
"checkIntegrity": "Controleer integriteit",
"stopIntegrity": "Stop integriteitscontrole"
},
"branding": {
"title": "Huisstijl",
@@ -652,7 +688,9 @@
"inwxUsername": "INWX gebruikersnaam",
"inwxPassword": "INWX wachtwoord",
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers",
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt."
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt.",
"carddavLocation": "CardDAV-server locatie",
"caldavLocation": "CalDAV server locatie"
},
"title": "Domeinen",
"domain": "Domein",
@@ -665,7 +703,9 @@
"changeDashboardDomain": {
"changeAction": "Domein aanpassen",
"title": "Dashboard Domein",
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein"
"description": "Verander het Dashboard naar het “my” subdomein van het geselecteerde domein",
"confirmMessage": "Dit zal alle passkeys voor gebruikers ongeldig maken.",
"confirmTitle": "Wil je echt het dashboard-domein wijzigen?"
},
"removeDialog": {
"title": "Verwijder domein",
@@ -746,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": {
@@ -854,14 +894,15 @@
"packageVersion": "Pakketversie",
"lastUpdated": "Laatst geüpdatet",
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
"installedAt": "Geïnstalleerd"
"installedAt": "Geïnstalleerd",
"packager": "Pakketmaker"
},
"auto": {
"description": "App updates worden uitgevoerd op basis van de <a href=\"/#/system-update\">update planning</a>.",
"title": "Automatische updates"
},
"updates": {
"description": "Cloudron controleert automatisch de App Store op updates. Je kunt ook handmatig controleren."
"description": "Cloudron controleert automatisch op app-updates. Je kunt dit ook handmatig controleren."
}
},
"backups": {
@@ -904,11 +945,6 @@
}
},
"uninstall": {
"startStop": {
"startAction": "Start",
"stopAction": "Stop",
"description": "Apps kunnen ook gestopt worden in plaats van de-installeren om server capaciteit vrij te maken. Toekomstige app backups bevatten geen wijzigingen tussen nu en de laatste app backup. Start daarom handmatig een backup alvorens de app te stoppen."
},
"uninstall": {
"title": "De-installeer",
"uninstallAction": "De-installeer",
@@ -1022,6 +1058,16 @@
"forumAction": "Forum",
"appLink": {
"title": "Externe Link"
},
"start": {
"title": "Start",
"description": "Start de app om deze weer beschikbaar te maken.",
"action": "Start"
},
"stop": {
"action": "Stop",
"title": "Stop",
"description": "Stop de app om bronnen te besparen. Maak vóór het stoppen een back-up om recente wijzigingen te behouden."
}
},
"network": {
@@ -1112,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": {
@@ -1144,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": {
@@ -1201,12 +1254,19 @@
"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."
},
"allCaughtUp": "Alles bijgewerkt"
"allCaughtUp": "Alles bijgewerkt",
"title": "Notificaties",
"showAll": "Alles",
"showUnread": "Ongelezen",
"markUnread": "Markeer als ongelezen",
"markRead": "Markeer als gelezen"
},
"logs": {
"title": "Logbestanden",
@@ -1230,11 +1290,11 @@
"reallyDelete": "Wil je het echt verwijderen?"
},
"newDirectoryDialog": {
"title": "Nieuwe mapnaam",
"title": "Nieuwe map",
"create": "Aanmaken"
},
"newFileDialog": {
"title": "Nieuw bestandsnaam",
"title": "Nieuwe bestandsnaam",
"create": "Aanmaken"
},
"renameDialog": {
@@ -1252,15 +1312,16 @@
"newFolder": "Nieuwe map",
"uploadFolder": "Upload map",
"openTerminal": "Open terminal",
"openLogs": "Open logbestanden"
"openLogs": "Open logbestanden",
"refresh": "Ververs"
},
"extractionInProgress": "Bezig met uitpakken",
"pasteInProgress": "Bezig met plakken",
"deleteInProgress": "Bezig met verwijderen",
"chownDialog": {
"title": "Eigenaarschap veranderen",
"title": "Eigenaar veranderen",
"newOwner": "Nieuwe eigenaar",
"change": "Eigenaar aanpassen",
"change": "Eigenaar veranderen",
"recursiveCheckbox": "Eigenaar recursief aanpassen"
},
"uploadingDialog": {
@@ -1481,7 +1542,10 @@
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw",
"loginAction": "Inloggen"
"loginAction": "Inloggen",
"usePasskeyAction": "Gebruik een passkey",
"errorPasskeyFailed": "Inloggen met passkey mislukt",
"passkeyAction": "Inloggen met een passkey"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1625,7 +1689,8 @@
"archives": {
"listing": {
"placeholder": "Geen gearchiveerde apps"
}
},
"description": "Gearchiveerde apps bewaren de laatst gemaakte backup op het moment van archiveren. Deze backups worden permanent bewaard en kunnen worden hersteld."
},
"backup": {
"target": {
@@ -1636,7 +1701,9 @@
"sites": {
"title": "Backup Locaties",
"emptyPlaceholder": "Geen backup locaties",
"lastRun": "Laatste uitvoering"
"lastRun": "Laatste uitvoering",
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld.",
"noAutomaticUpdateBackupWarning": "Er is geen back-uplocatie geconfigureerd om back-ups op te slaan voor automatische updates. Schakel \"Hier automatische back-ups opslaan\" in op minstens één back-uplocatie om automatische updates mogelijk te maken."
},
"site": {
"removeDialog": {
@@ -1675,5 +1742,9 @@
},
"server": {
"title": "Server"
},
"communityapp": {
"installwarning": "Community-apps worden niet door Cloudron beoordeeld. Installeer alleen apps van betrouwbare ontwikkelaars. Code van derden kan uw systeem in gevaar brengen.",
"unstablewarning": "Deze app is door de ontwikkelaar gemarkeerd als onstabiel."
}
}

View File

@@ -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"

View File

@@ -40,7 +40,8 @@
"displayName": "Отображаемое имя",
"actions": "Действия",
"table": {
"version": "Версия"
"version": "Версия",
"created": "Создано"
},
"action": {
"reboot": "Перезагрузка",
@@ -51,7 +52,8 @@
"next": "Следующий",
"configure": "Настроить",
"restart": "Перезапуск",
"reset": "Сброс"
"reset": "Сброс",
"loadMore": "Загрузить ещё"
},
"searchPlaceholder": "Поиск",
"multiselect": {
@@ -103,6 +105,9 @@
"appNotFoundDialog": {
"title": "Приложение не найдено",
"description": "Не найдено приложения <b>{{ appId }}</b> версии <b>{{ version }}</b>."
},
"action": {
"addCustomApp": "Добавить стороннее приложение"
}
},
"users": {
@@ -278,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",
@@ -326,7 +336,8 @@
"name": "Имя пароля",
"app": "Приложение",
"description": "Используйте этот пароль для аутентификации в приложении:",
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности."
"copyNow": "Пожалуйста, скопируйте сгенерированный пароль. Он не будет показан снова из соображений безопасности.",
"expiresAt": "Истекает в"
},
"createApiToken": {
"copyNow": "Пожалуйста, скопируйте сгенерированный API Токен. Он не будет показан снова из соображений безопасности.",
@@ -337,8 +348,6 @@
"allowedIpRanges": "Разрешённые диапазоны IP"
},
"changePasswordAction": "Изменить пароль",
"disable2FAAction": "Выключить 2FA",
"enable2FAAction": "Включить 2FA",
"passwordResetNotification": {
"body": "Письмо отправлено на адрес электронной почты {{ email }}"
},
@@ -349,6 +358,11 @@
"removeAppPassword": {
"title": "Удалить пароль приложения",
"description": "Удалить пароль приложения \"{{ name }}\" ?"
},
"twoFactorAuth": {
"title": "Двухфакторная аутентификация",
"totpEnabled": "Используется одноразовый пароль (TOTP)",
"passkeyEnabled": "Используется ключ доступа"
}
},
"app": {
@@ -362,21 +376,22 @@
"customAppUpdateInfo": "Для сторонних приложений автообновления недоступны.",
"description": "Название & версия приложения",
"appId": "ID приложения",
"packageVersion": "Версия контейнера",
"packageVersion": "Версия пакета",
"lastUpdated": "Обновлен",
"installedAt": "Установлено"
"installedAt": "Установлено",
"packager": "Сборщик"
},
"auto": {
"title": "Автоматические обновления",
"description": "Обновления приложения применяются периодически, в соответствии с <a href=\"/#/system-update\">расписанием обновлений</a>"
},
"updates": {
"description": "Cloudron автоматически проверяет Магазин приложений на наличие обновлений. Вы также можете проверить их вручную."
"description": "Cloudron автоматически проверяет наличие обновлений для приложений. Вы также можете проверить их вручную."
}
},
"backups": {
"backups": {
"description": "Создать полный снимок приложения.",
"description": "Создать полный снимок приложения",
"title": "Резервные копии",
"downloadConfigTooltip": "Скачать конфигурацию",
"cloneTooltip": "Клонировать",
@@ -388,7 +403,7 @@
},
"import": {
"title": "Импортировать",
"description": "Импортировать приложение из внешней резервной копии."
"description": "Импортировать приложение из внешней резервной копии"
},
"auto": {
"title": "Автоматические резервные копии",
@@ -404,8 +419,7 @@
"saveAction": "Сохранить",
"aliases": "Псевдонимы",
"addAliasAction": "Добавить псевдоним",
"noAliases": "Домены-псевдонимы отсутствуют",
"dnsoverwrite": "Некоторые DNS записи уже существуют. Подтвердите перезапись."
"noAliases": "Домены-псевдонимы отсутствуют"
},
"accessControl": {
"sftp": {
@@ -416,10 +430,10 @@
},
"operators": {
"title": "Операторы",
"description": "Операторы могут настраивать и поддерживать работу этого приложения."
"description": "Настроить, кто может поддерживать работу приложения"
},
"userManagement": {
"description": "Настроить, кто может входить и использовать это приложение.",
"description": "Настроить, кто может входить и использовать это приложение",
"descriptionSftp": "Данный параметр также контролирует доступ к SFTP.",
"dashboardVisibility": "Видимость в панели управления",
"visibleForAllUsers": "Отображать для всех пользователей Cloudron",
@@ -503,7 +517,7 @@
"hourly": "Каждый час",
"service": "Проверить (один запуск)"
},
"addCommonPattern": "Добавить общий шаблон",
"addCommonPattern": "Вставить общий шаблон",
"description": "Задания Cron, требуемые для правильной работы приложения, уже интегрированы в контейнер. Здесь можно настроить прочие задания."
},
"display": {
@@ -553,11 +567,27 @@
"csp": {
"title": "Политика безопасности контента",
"saveAction": "Сохранить",
"description": "Перезаписать любые CSP заголовки, отправляемые приложением."
"description": "Перезаписать любые CSP заголовки, отправляемые приложением",
"insertCommonCsp": "Вставить стандартный CSP",
"commonPattern": {
"allowEmbedding": "Разрешить встраивание",
"sameOriginEmbedding": "Разрешить встраивание (только поддомены)",
"allowCdnAssets": "Разрешить использование ресурсов CDN",
"reportOnly": "Сообщить о нарушениях CSP",
"strictBaseline": "Строгий базовый уровень"
}
},
"robots": {
"title": "Robots.txt",
"description": "По умолчанию, роботы могут индексировать это приложение."
"description": "По умолчанию, роботы могут индексировать это приложение",
"commonPattern": {
"allowAll": "Разрешить все (по умолчанию)",
"disallowAll": "Запретить все",
"disallowCommonBots": "Запретить известных ботов",
"disallowAdminPaths": "Запретить пути админа",
"disallowApiPaths": "Запретить пути API"
},
"insertCommonRobotsTxt": "Вставить стандартный robots.txt"
},
"hstsPreload": "Активировать предзагрузку HSTS (в том числе для поддоменов)"
},
@@ -580,11 +610,6 @@
}
},
"uninstall": {
"startStop": {
"description": "Вместо удаления, приложение может быть остановлено для освобождения ресурсов сервера. Будущие резервные копии не сохранят текущее состояние приложения до момента остановки. Рекомендуется запустить процесс резервного копирования вручную до остановки работы приложения.",
"startAction": "Запустить",
"stopAction": "Остановить"
},
"uninstall": {
"title": "Удаление",
"description": "Удалить приложение и все его данные. Резервные копии очищаются в соответствии с политикой резервного копирования.",
@@ -627,7 +652,7 @@
"cloneDialog": {
"title": "Клонировать приложение",
"location": "Расположение",
"description": "Клон использует резервную копию версии <b>v{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
"description": "Клон использует резервную копию версии <b>{{ packageVersion }}</b> от <b>{{ creationTime }}</b>."
},
"addApplinkDialog": {
"title": "Добавить Внешнюю ссылку"
@@ -670,6 +695,16 @@
"forumAction": "Форум",
"appLink": {
"title": "Внешняя ссылка"
},
"start": {
"title": "Старт",
"description": "Запустить приложение и сделать его снова доступным.",
"action": "Старт"
},
"stop": {
"action": "Стоп",
"title": "Стоп",
"description": "Остановить приложение, чтобы сохранить ресурсы. Создайте резервную копию перед этим, чтобы сохранить последние изменения."
}
},
"backups": {
@@ -686,7 +721,8 @@
"tooltipDownloadBackupConfig": "Скачать конфигурацию",
"cleanupBackups": "Очистить резервные копии",
"backupNow": "Создать копию",
"tooltipPreservedBackup": "Резервная копия будет сохранена"
"tooltipPreservedBackup": "Резервная копия будет сохранена",
"description": "Системные резервные копии содержат настройки Cloudron и метаданные приложений. Они могут быть использованы для <a href=\"{{restoreLink}}\" target=\"_blank\">восстановления</a> или <a href=\"{{migrateLink}}\" target=\"_blank\">переноса</a> Cloudron на другой сервер."
},
"schedule": {
"title": "Расписание & политика хранения",
@@ -772,11 +808,14 @@
"title": "Резервные копии",
"backupDetails": {
"title": "Детали резервного копирования",
"id": "Id",
"date": "Дата",
"version": "Версия",
"id": "ID Резервной копии",
"date": "Создано",
"version": "Версия пакета",
"size": "Размер",
"duration": "Продолжительность"
"duration": "Продолжительность резервного копирования",
"lastIntegrityCheck": "Последняя проверка целостности",
"integrityNever": "никогда",
"integrityInProgress": "В процессе"
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -818,7 +857,9 @@
"title": "Настроить содержание резервной копии"
},
"useFileAndFileNameEncryption": "Используется шифрование файлов и их имён",
"useFileEncryption": "Используется шифрование файлов"
"useFileEncryption": "Используется шифрование файлов",
"checkIntegrity": "Проверить целостность",
"stopIntegrity": "Остановить проверку целостности"
},
"branding": {
"title": "Брендирование",
@@ -1005,17 +1046,13 @@
"updateAvailableAction": "Доступно обновление",
"stopUpdateAction": "Остановить обновление",
"description": "Обновления платформы и приложений запускаются с учётом установленного расписания и в соответствии с <a href=\"/#/system-settings\">системным часовым поясом</a>.",
"schedule": "Расписание обновлений",
"disabled": "Выключено",
"onLatest": "последний"
},
"updateScheduleDialog": {
"title": "Настроить расписание автоматических обновлений",
"disableCheckbox": "Выключить автоматические обновления",
"enableCheckbox": "Включить автоматические обновления",
"selectOne": "Выберите по крайней мере один день и время",
"days": "Дни",
"hours": "Часы",
"description": "Установите дни и часы, в которые будет происходить автоматическое обновление платформы и приложений. Убедитесь, что установленное расписание не пересекается с расписанием резервного копирования."
},
"updateDialog": {
@@ -1092,7 +1129,9 @@
"changeDashboardDomain": {
"title": "Домен панели управления",
"changeAction": "Изменить домен",
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена"
"description": "Изменяет поддомен панели управления \"my\" для выбранного домена",
"confirmMessage": "Это действие сбросит ключи доступа для всех пользователей.",
"confirmTitle": "Вы точно хотите сменить домен панели управления?"
},
"domainDialog": {
"editTitle": "Редактировать домен",
@@ -1150,7 +1189,9 @@
"inwxUsername": "Имя пользователя INWX",
"inwxPassword": "Пароль INWX",
"customNameservers": "Домен использует пользовательские серверы имён (vanity)",
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен."
"zoneNamePlaceholder": "Необязательно. Если не указано, используется корневой домен.",
"carddavLocation": "Расположение сервера CardDAV",
"caldavLocation": "Расположение сервера CalDAV"
},
"removeDialog": {
"title": "Удалить домен",
@@ -1189,7 +1230,12 @@
"allCaughtUp": "Уведомления отсутствуют",
"settingsDialog": {
"description": "Уведомления о выбранных событиях будут отправлены на основной Email."
}
},
"title": "Уведомления",
"showAll": "Все",
"showUnread": "Непрочитанные",
"markUnread": "Отметить как непрочитанные",
"markRead": "Отметить как прочитанные"
},
"logs": {
"title": "Логи",
@@ -1210,7 +1256,7 @@
"filemanager": {
"title": "Файловый менеджер",
"newDirectoryDialog": {
"title": "Имя новой папки",
"title": "Новая папка",
"create": "Создать"
},
"newFileDialog": {
@@ -1232,7 +1278,8 @@
"restartApp": "Перезагрузить приложение",
"uploadFolder": "Загрузить папку",
"openTerminal": "Открыть терминал",
"openLogs": "Открыть логи"
"openLogs": "Открыть логи",
"refresh": "Обновить"
},
"removeDialog": {
"reallyDelete": "Действительно удалить?"
@@ -1241,7 +1288,7 @@
"pasteInProgress": "Выполняется копирование / перемещение",
"deleteInProgress": "Выполняется удаление",
"chownDialog": {
"title": "Смена владельца",
"title": "Изменить владельца",
"newOwner": "Новый владелец",
"change": "Изменить владельца",
"recursiveCheckbox": "Изменить владельца рекурсивно"
@@ -1272,7 +1319,7 @@
"symlink": "Символическая ссылка на {{ target }}",
"menu": {
"rename": "Переименовать",
"chown": "Изменить владельца",
"chown": "Смена владельца",
"extract": "Распаковать здесь",
"download": "Скачать",
"delete": "Удалить",
@@ -1464,7 +1511,8 @@
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginAction": "Войти"
"loginAction": "Войти",
"usePasskeyAction": "Использовать ключ доступа"
},
"passwordReset": {
"title": "Сброс пароля",
@@ -1608,7 +1656,8 @@
"archives": {
"listing": {
"placeholder": "Архивные приложения отсутствуют"
}
},
"description": "В архивированном приложении сохраняется его последняя резервная копия. Эта копия хранится постоянно и может быть восстановлена в любой момент."
},
"backup": {
"target": {
@@ -1619,7 +1668,9 @@
"sites": {
"title": "Локации резервных копий",
"emptyPlaceholder": "Локации отсутствуют",
"lastRun": "Последний запуск"
"lastRun": "Последний запуск",
"description": "Локации резервных копий указывают на то, где будут сохраняться копии системы и приложений. Резервные копии приложений могут быть восстановлены по-отдельности.",
"noAutomaticUpdateBackupWarning": "Не настроено ни одной локации резервных копий для хранения копий автоматических обновлений. Включите \"Хранить бэкапы автоматических обновлений здесь\" по крайней мере в одной локации, чтобы активировать автоматические обновления."
},
"site": {
"removeDialog": {
@@ -1658,5 +1709,9 @@
},
"server": {
"title": "Сервер"
},
"communityapp": {
"installwarning": "Cloudron не проводит аудит приложений, созданных сообществом. Устанавливайте приложения только от проверенных разработчиков. Сторонний код может поставить под угрозу безопасности вашей системы.",
"unstablewarning": "Разработчик пометил это приложение как нестабильное."
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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": {
@@ -1034,11 +1029,6 @@
}
},
"uninstall": {
"startStop": {
"startAction": "启动应用",
"description": "可以通过停止应用(而非卸载)来节省服务器资源。停用后的自动备份不会包括当前的状态,有鉴于此,建议你在停止应用之前进行一次手动备份。",
"stopAction": "停止应用"
},
"uninstall": {
"description": "将会卸载此应用,并删除所有数据。卸载后该应用将不可用。",
"title": "卸载",

View File

@@ -5,14 +5,16 @@ const i18n = useI18n();
const t = i18n.t;
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
import { Notification, fetcher } from '@cloudron/pankow';
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';
@@ -34,6 +36,7 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
import EmailEventlogView from './views/EmailEventlogView.vue';
import EventlogView from './views/EventlogView.vue';
import NetworkView from './views/NetworkView.vue';
import NotificationsView from './views/NotificationsView.vue';
import ProfileView from './views/ProfileView.vue';
import ServicesView from './views/ServicesView.vue';
import SystemSettingsView from './views/SystemSettingsView.vue';
@@ -64,6 +67,7 @@ const VIEWS = Object.freeze({
EMAIL_EVENTLOG: '#/email-eventlog',
SERVER: '#/server',
NETWORK: '#/network',
NOTIFICATIONS: '#/notifications',
PROFILE: '#/profile',
SERVICES: '#/services',
SYSTEM_SETTINGS: '#/system-settings',
@@ -273,12 +277,16 @@ fetcher.globalOptions.errorHook = (error) => {
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');
const ready = ref(false);
const view = ref('');
const profile = ref({});
const dashboardDomain = ref('');
const notificationCount = ref(0);
const subscription = ref({
plan: {},
});
@@ -319,6 +327,8 @@ function onHashChange() {
view.value = VIEWS.EMAIL_EVENTLOG;
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SERVER;
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
view.value = VIEWS.NOTIFICATIONS;
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
view.value = VIEWS.NETWORK;
} else if (v === VIEWS.PROFILE) {
@@ -381,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;
@@ -388,6 +401,20 @@ async function refreshConfigAndFeatures() {
dashboardDomain.value = result.adminDomain;
}
async function refreshNotifications() {
const [error, result] = await notificationModel.list(false);
if (error) return console.error(error);
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
@@ -399,12 +426,15 @@ function checkForMobile() {
}
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
provide('subscription', subscription);
provide('features', features);
provide('profile', profile);
provide('refreshProfile', refreshProfile);
provide('refreshFeatures', refreshConfigAndFeatures);
provide('refreshNotifications', refreshNotifications);
provide('dashboardDomain', dashboardDomain);
provide('isMobile', isMobile);
provide('inputDialog', inputDialog);
onMounted(async () => {
window.addEventListener('resize', checkForMobile);
@@ -417,29 +447,36 @@ 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`;
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.href = VIEWS.PROFILE;
window.addEventListener('hashchange', onHashChange);
onHashChange();
console.log(`Cloudron dashboard v${config.value.version}`);
if (profile.value.isAtLeastAdmin) {
refreshNotifications();
refreshSubscription();
}
ready.value = true;
// 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;
}
});
onUnmounted(() => {
@@ -454,12 +491,13 @@ onUnmounted(() => {
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
<RequestErrorDialog/>
<InputDialog ref="inputDialog"/>
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
<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"/>
<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>
@@ -481,6 +519,7 @@ onUnmounted(() => {
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
<ServerView v-else-if="view === VIEWS.SERVER" />
<NetworkView v-else-if="view === VIEWS.NETWORK" />
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
<ProfileView v-else-if="view === VIEWS.PROFILE" />
<ServicesView v-else-if="view === VIEWS.SERVICES" />
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />

View File

@@ -43,7 +43,7 @@ const cloudronAuth = computed(() => {
<template>
<div>
<FormGroup>
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-control" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
<div v-if="!cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
<div v-if="manifest.addons.email" v-html="$t('appstore.installDialog.userManagementMailbox')"></div>
@@ -52,7 +52,7 @@ const cloudronAuth = computed(() => {
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
<FormGroup>
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.dashboardVisibility.description') }}</div>
</FormGroup>
@@ -66,7 +66,7 @@ const cloudronAuth = computed(() => {
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED" style="margin-top: 12px; margin-left: 20px; display: flex; gap: 10px;">
<div>
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
</div>
<div>
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />

View File

@@ -11,6 +11,8 @@ const props = defineProps({
});
const quickActions = computed(() => {
if (window.innerWidth <= 576) return [];
const visibleActions = props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator);
if (visibleActions.length <= 2) return visibleActions;
@@ -35,7 +37,7 @@ function onMenu(event) {
<div class="action-bar" :class="{ 'is-menu-open': isMenuOpen }">
<Menu ref="menuElement" :model="actions" @close="isMenuOpen = false" />
<ButtonGroup class="quick-action-group">
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action && quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
<Button tool @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0 && visibleActionCount !== quickActions.length"/>
</ButtonGroup>
<Button tool :plain="isMenuOpen ? null : true" secondary @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0" class="menu-action" :class="{ 'hide-on-touch': visibleActionCount === quickActions.length }"/>

View File

@@ -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,20 +186,20 @@ 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')">
<template #lastUsedTime="apiToken">
<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>
</template>
<template #scope="apiToken">
<template #scope="{ item:apiToken }">
<span v-if="apiToken.scope['*'] === 'rw'">{{ $t('profile.apiTokens.readwrite') }}</span>
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
</template>
<template #allowedIpRanges="apiToken">
<template #allowedIpRanges="{ item:apiToken }">
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
<span v-else>{{ '*' }}</span>
</template>
<template #actions="apiToken">
<template #actions="{ item:apiToken }">
<ActionBar :actions="createActionMenu(apiToken)" />
</template>
</TableView>

View File

@@ -1,17 +1,16 @@
<script setup>
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
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 { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
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';
import AppsModel from '../models/AppsModel.js';
import AppstoreModel from '../models/AppstoreModel.js';
import DomainsModel from '../models/DomainsModel.js';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
import { API_ORIGIN, PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
const STEP = Object.freeze({
LOADING: Symbol('loading'),
@@ -19,7 +18,6 @@ const STEP = Object.freeze({
INSTALL: Symbol('install'),
});
const appstoreModel = AppstoreModel.create();
const appsModel = AppsModel.create();
const domainsModel = DomainsModel.create();
const usersModel = UsersModel.create();
@@ -31,7 +29,10 @@ const dashboardDomain = inject('dashboardDomain');
// reactive
const busy = ref(false);
const formError = ref({});
const app = ref({});
// community { iconUrl, versionsUrl, manifest, publishState, creationDate, ts }
// appstore { id, iconUrl, appStoreId, manifest, creationDate, publishState }
const packageData = ref({});
const manifest = ref({});
const step = ref(STEP.DETAILS);
const dialog = useTemplateRef('dialogHandle');
@@ -39,23 +40,34 @@ const locationInput = useTemplateRef('locationInput');
const description = computed(() => marked.parse(manifest.value.description || ''));
const domains = ref([]);
const formValid = computed(() => {
if (!domain.value) return false;
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 = {};
}
if (location.value && !isValidDomain(location.value + '.' + domain.value)) return false;
async function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
if (isFormValid.value) {
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) {
isFormValid.value = true;
}
if (manifest.value.id === PROXY_APP_ID) {
try {
new URL(upstreamUri.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
if (manifest.value.id === PROXY_APP_ID) {
try {
new URL(upstreamUri.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
isFormValid.value = false;
}
}
}
return true;
}
watch(form, () => { // trigger form validation when the ref becomes set
setTimeout(checkValidity, 100);
});
const appMaxCountExceeded = ref(false);
@@ -68,7 +80,9 @@ function setStep(newStep) {
}
step.value = newStep;
if (newStep === STEP.INSTALL) setTimeout(() => locationInput.value.$el.focus(), 500);
if (newStep === STEP.INSTALL) {
setTimeout(() => locationInput.value.$el.focus(), 500);
}
}
// form data
@@ -81,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([]);
@@ -90,7 +105,9 @@ function onDomainChange() {
domainProvider.value = tmp ? tmp.provider : '';
}
async function onSubmit(overwriteDns) {
async function onSubmit() {
if (!form.value.reportValidity()) return;
formError.value = {};
busy.value = true;
@@ -101,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) {
@@ -109,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 = {
@@ -123,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;
@@ -148,12 +168,12 @@ async function onSubmit(overwriteDns) {
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
const [error, result] = await appsModel.install(manifest.value, config);
const [error, result] = await appsModel.install(packageData.value, config);
if (!error) {
dialog.value.close();
localStorage['confirmPostInstall_' + result.id] = true;
return window.location.href = '/#/apps';
if (manifest.value.postInstallMessage) localStorage['confirmPostInstall_' + result.id] = true;
return window.location.href = `/#/app/${result.id}/info`;
}
busy.value = false;
@@ -175,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();
@@ -201,41 +221,22 @@ function onScreenshotNext() {
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
}
async function getApp(id, version = '') {
const [error, result] = await appstoreModel.get(id, version);
if (error) {
console.error(error);
return null;
}
return result;
}
defineExpose({
open: async function(appId, version, appCountExceeded, domainList) {
open: async function(pd, appCountExceeded, domainList) {
busy.value = false;
step.value = STEP.LOADING;
formError.value = {};
// give it some time to fetch before showing loading
const openTimer = setTimeout(dialog.value.open, 200);
const a = await getApp(appId, version);
if (!a) {
clearTimeout(openTimer);
dialog.value.close();
throw new Error('app not found');
}
app.value = a;
packageData.value = pd;
appMaxCountExceeded.value = appCountExceeded;
manifest.value = a.manifest;
manifest.value = packageData.value.manifest;
location.value = '';
accessRestrictionOption.value = ACL_OPTIONS.ANY;
accessRestrictionAcl.value = { users: [], groups: [] };
domainProvider.value = '';
upstreamUri.value = '';
needsOverwriteDns.value = '';
overwriteDns.value = false;
needsOverwriteDns.value = [];
domainList.forEach(d => {
d.label = '.' + d.domain;
@@ -246,8 +247,8 @@ defineExpose({
// preselect with dashboard domain
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
tcpPorts.value = a.manifest.tcpPorts;
udpPorts.value = a.manifest.udpPorts;
tcpPorts.value = manifest.value.tcpPorts;
udpPorts.value = manifest.value.udpPorts;
// ensure we have value property
for (const p in tcpPorts.value) {
@@ -259,7 +260,7 @@ defineExpose({
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
}
secondaryDomains.value = a.manifest.httpPorts;
secondaryDomains.value = manifest.value.httpPorts;
for (const p in secondaryDomains.value) {
const port = secondaryDomains.value[p];
port.value = port.defaultValue;
@@ -268,6 +269,7 @@ defineExpose({
currentScreenshotPos = 0;
step.value = STEP.DETAILS;
dialog.value.open();
},
close() {
dialog.value.close();
@@ -283,15 +285,14 @@ defineExpose({
</div>
<div v-else class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
<div class="app-install-header">
<div class="summary" v-if="app.manifest">
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
<div class="summary" v-if="packageData.manifest">
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>{{ manifest.title }}</div>
<div><a :href="manifest.website" target="_blank">{{ manifest.title }}</a> {{ packageData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ packageData.manifest.version }}</div>
<div v-if="packageData.versionsUrl"><a :href="packageData.manifest.packagerUrl" target="_blank">{{ packageData.manifest.packagerName }}</a></div>
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(packageData.creationDate) }) }}</div>
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
</div>
<img class="icon pankow-no-mobile" :src="app.iconUrl" />
<img class="icon pankow-no-mobile" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</div>
<Transition name="slide-left" mode="out-in">
<div v-if="step === STEP.DETAILS">
@@ -306,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 @submit.prevent="onSubmit(false)" autocomplete="off">
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none;" type="submit" :disabled="!formValid" />
<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"/>
<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>
@@ -327,22 +325,26 @@ 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" />
<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>
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-show="manifest.id === PROXY_APP_ID">
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-if="manifest.id === PROXY_APP_ID">
<label for="upstreamUri">Upstream URI</label>
<TextInput id="upstreamUri" v-model="upstreamUri" />
<TextInput id="upstreamUri" v-model="upstreamUri" required/>
</FormGroup>
<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="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="!formValid" :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>
@@ -370,7 +372,6 @@ defineExpose({
.app-install-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}

View File

@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import moment from 'moment-timezone';
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
import { Button, ClipboardButton, DateTimeInput, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import ActionBar from './ActionBar.vue';
import Section from './Section.vue';
@@ -35,7 +34,16 @@ const columns = {
sort(a, b) {
if (!a) return 1;
if (!b) return -1;
return moment(a).isBefore(b) ? 1 : -1;
return new Date(a) - new Date(b);
}
},
expiresAt: {
label: t('profile.appPasswords.expires'),
hideMobile: true,
sort(a, b) {
if (!a) return 1;
if (!b) return -1;
return new Date(a) - new Date(b);
}
},
actions: {}
@@ -54,22 +62,31 @@ const addedPassword = ref('');
const passwordName = ref('');
const identifiers = ref([]);
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 = {};
async function refresh() {
const [error, result] = await appPasswordsModel.list();
if (error) return console.error(error);
// setup label for the table UI
result.forEach(function (password) {
if (password.identifier === 'mail') return password.label = password.identifier;
const app = appsById[password.identifier];
if (!app) return password.label = password.identifier + ' (App not found)';
for (const password of result) {
if (password.identifier === 'mail') {
password.label = password.identifier;
} else {
const app = appsById[password.identifier];
if (!app) return password.label = password.identifier + ' (App not found)';
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
const labelSuffix = ftp ? ' - SFTP' : '';
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
});
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
const labelSuffix = ftp ? ' - SFTP' : '';
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
}
password.expired = password.expiresAt && new Date(password.expiresAt) < new Date();
}
passwords.value = result;
}
@@ -84,7 +101,10 @@ function onReset() {
setTimeout(() => {
passwordName.value = '';
identifier.value = '';
expiresAtDate.value = '';
addedPassword.value = '';
addError.value = '';
busy.value = false;
setTimeout(checkValidity, 100); // update state of the confirm button
}, 500);
}
@@ -92,16 +112,26 @@ function onReset() {
async function onSubmit() {
if (!form.value.reportValidity()) return;
busy.value = true;
addError.value = '';
addedPassword.value = '';
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value);
if (error) return console.error(error);
const expiresAt = expiresAtDate.value ? new Date(expiresAtDate.value).toISOString() : null;
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value, expiresAt);
if (error) {
busy.value = false;
addError.value = error.body ? error.body.message : 'Internal error';
return;
}
addedPassword.value = result.password;
passwordName.value = '';
identifier.value = '';
expiresAtDate.value = '';
await refresh();
busy.value = false;
}
async function onRemove(appPassword) {
@@ -134,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;
@@ -150,6 +180,7 @@ onMounted(async () => {
});
await refresh();
loading.value = false;
});
</script>
@@ -160,7 +191,8 @@ onMounted(async () => {
<Dialog ref="newDialog"
:title="$t('profile.createAppPassword.title')"
:confirm-active="addedPassword || isFormValid"
:confirm-busy="busy"
:confirm-active="addedPassword || (!busy && isFormValid)"
:confirm-label="addedPassword ? '' : $t('main.action.add')"
confirm-style="primary"
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
@@ -169,19 +201,27 @@ onMounted(async () => {
@close="onReset()"
>
<div>
<div class="error-label" v-show="addError">{{ addError }}</div>
<Transition name="slide-left" mode="out-in">
<div v-if="!addedPassword">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<input style="display: none" type="submit"/>
<FormGroup>
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
<TextInput id="passwordName" v-model="passwordName" required/>
</FormGroup>
<fieldset :disabled="busy">
<input style="display: none" type="submit"/>
<FormGroup>
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
<TextInput id="passwordName" v-model="passwordName" required/>
</FormGroup>
<FormGroup>
<label>{{ $t('profile.createAppPassword.app') }}</label>
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
</FormGroup>
<FormGroup>
<label>{{ $t('profile.createAppPassword.app') }}</label>
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
</FormGroup>
<FormGroup>
<label for="expiresAt">{{ $t('profile.createAppPassword.expiresAt') }} (optional)</label>
<DateTimeInput id="expiresAt" v-model="expiresAtDate" :min="minExpiresAt"/>
</FormGroup>
</fieldset>
</form>
</div>
<div v-else>
@@ -204,9 +244,15 @@ onMounted(async () => {
<div>{{ $t('profile.appPasswords.description') }}</div>
<br/>
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
<template #actions="password">
<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>
<template #expiresAt="{ item:password }">
<span :class="{ 'text-muted': password.expired }" v-if="!password.expiresAt">-</span>
<span :class="{ 'text-muted': password.expired }" v-else>{{ prettyLongDate(password.expiresAt) }}</span>
</template>
<template #actions="{ item:password }">
<ActionBar :actions="createActionMenu(password)" />
</template>
</TableView>

View File

@@ -208,6 +208,8 @@ defineExpose({
<div class="error-label" v-show="formError.port">{{ formError.port }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<input type="submit" style="display: none;"/>
<fieldset>
<FormGroup>
<label for="locationInput">{{ $t('app.cloneDialog.location') }}</label>

View File

@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { computed, ref, useTemplateRef } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { Dialog, FormGroup, InputDialog, MultiSelect, Radiobutton, TagInput, TextInput } from '@cloudron/pankow';
import { API_ORIGIN } from '../constants.js';
import { getDataURLFromFile } from '../utils.js';
@@ -64,16 +64,17 @@ async function onSubmit() {
busy.value = true;
const data = {
label: label.value,
upstreamUri: upstreamUri.value,
tags: tags.value,
};
if (label.value) data.label = label.value;
data.accessRestriction = null;
if (accessRestrictionOption.value === 'groups') {
data.accessRestriction = { users: [], groups: [] };
data.accessRestriction.users = accessRestriction.value.users.map(function (u) { return u.id; });
data.accessRestriction.groups = accessRestriction.value.groups.map(function (g) { return g.id; });
data.accessRestriction.users = accessRestriction.value.users;
data.accessRestriction.groups = accessRestriction.value.groups;
}
if (iconFile === 'fallback') { // user reset the icon
@@ -130,6 +131,7 @@ defineExpose({
// fetch users and groups
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => { u.label = u.username || u.email; });
users.value = result;
[error, result] = await groupsModel.list();
@@ -177,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>
@@ -188,7 +190,7 @@ defineExpose({
</FormGroup>
<FormGroup>
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<Radiobutton v-model="accessRestrictionOption" value="any" :label="$t('app.accessControl.userManagement.visibleForAllUsers')"/>
<Radiobutton v-model="accessRestrictionOption" value="groups" :label="$t('app.accessControl.userManagement.visibleForSelected')"/>
<!-- <span class="label label-danger"v-show="accessRestrictionOption === 'groups' && !isAccessRestrictionValid(applinkDialogData)">{{ $t('appstore.installDialog.errorUserManagementSelectAtLeastOne') }}</span> -->
@@ -197,10 +199,10 @@ defineExpose({
<div v-if="accessRestrictionOption === 'groups'">
<div style="margin-left: 20px; display: flex;">
<div>
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-label="username" :search-threshold="20" />
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
</div>
<div>
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-label="name" :search-threshold="20" />
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { ref, computed, useTemplateRef } from 'vue';
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import AppsModel from '../models/AppsModel.js';
@@ -15,24 +15,39 @@ const backupsModel = BackupsModel.create();
const busy = ref(true);
const backupContentTableColumns = {
label: {
label: t('backups.listing.contents'),
sort: true,
},
fileCount: {
label: t('backup.target.fileCount'),
sort(a, b, A, B) {
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
const backupContentTableColumns = computed(() => {
const columns = {
label: {
label: t('backups.listing.contents'),
sort: true,
},
},
size: {
label: t('backup.target.size'),
sort(a, b, A , B) {
return A.stats?.upload?.size - B.stats?.upload?.size;
fileCount: {
label: t('backup.target.fileCount'),
sort(a, b, A, B) {
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
},
align: 'right',
},
size: {
label: t('backup.target.size'),
sort(a, b, A , B) {
return A.stats?.upload?.size - B.stats?.upload?.size;
},
align: 'right',
},
};
if (backup.value.lastIntegrityCheckTime || backup.value.integrityCheckTask) {
columns.integrity = {
label: 'Integrity',
sort: false,
width: '100px',
align: 'center',
};
}
};
return columns;
});
const backup = ref({ contents: [], validStats: false });
const dialog = useTemplateRef('dialog');
@@ -67,8 +82,11 @@ defineExpose({
if (!match) continue;
const [error, result] = await backupsModel.get(contentId);
if (error) console.error(error);
const content = { id: null, label: null, fqdn: null, stats: null };
const content = { id: null, label: null, fqdn: null, stats: null, integrityCheckStatus: null, lastIntegrityCheckTime: null, integrityCheckTask: null };
content.stats = result.stats;
content.integrityCheckStatus = result.integrityCheckStatus;
content.lastIntegrityCheckTime = result.lastIntegrityCheckTime;
content.integrityCheckTask = result.integrityCheckTask;
if (match[1] === 'mail') {
content.id = 'mail';
content.label = 'Mail Server';
@@ -132,25 +150,53 @@ defineExpose({
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupDetails.lastIntegrityCheck') }}</div>
<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 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 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'"/>
<div v-if="backup.type === 'box'">
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
<template #label="content">
<template #label="{ item:content }">
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
</template>
<template #fileCount="content">
<template #fileCount="{ item:content }">
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
<div v-else style="text-align: right">-</div>
</template>
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
<template #size="content">
<template #size="{ item:content }">
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
<div v-else style="text-align: right">-</div>
</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 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 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>
</TableView>
</div>
</Dialog>

View File

@@ -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>

View File

@@ -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') {

View File

@@ -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'">

View File

@@ -0,0 +1,92 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
import CommunityModel from '../models/CommunityModel.js';
const communityModel = CommunityModel.create();
const emit = defineEmits([ 'success' ]);
const dialog = useTemplateRef('dialog');
const form = useTemplateRef('form');
const urlInput = useTemplateRef('urlInput');
const formError = ref({});
const versionsUrl = ref('');
const busy = ref(false);
const unstable = ref(false);
const isFormValid = ref(false);
function validateForm() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
async function onSubmit() {
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = {};
const [url, version] = versionsUrl.value.split('@'); // hidden feature that user can input with version
const [error, result] = await communityModel.getApp(url, version || 'latest');
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
unstable.value = !!result.unstable;
const packageData = {
...result, // { manifest, publishState, creationDate, ts, unstable, versionsUrl }
versionsUrl: result.versionsUrl,
iconUrl: result.manifest.iconUrl // compat with app store format
};
emit('success', packageData);
dialog.value.close();
busy.value = false;
}
defineExpose({
open() {
versionsUrl.value = '';
formError.value = {};
unstable.value = false;
dialog.value.open();
setTimeout(validateForm, 100); // update state of the confirm button
setTimeout(() => urlInput.value.focus(), 500);
}
});
</script>
<template>
<Dialog ref="dialog"
title="Install Community App"
:confirm-label="$t('main.action.add')"
:confirm-busy="busy"
:confirm-active="!busy && isFormValid"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
@confirm="onSubmit()"
>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" />
<div class="warning-label">{{ $t('communityapp.installwarning') }}</div>
<div class="error-label" v-if="unstable">{{ $t('communityapp.unstablewarning') }}</div>
<FormGroup>
<label for="urlInput">CloudronVersions.json URL</label>
<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>
</form>
</Dialog>
</template>

View File

@@ -1,6 +1,10 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, inject } from 'vue';
import { Button, ProgressBar, SingleSelect, InputGroup } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { TASK_TYPES } from '../constants.js';
@@ -14,6 +18,8 @@ const taskModel = TasksModel.create();
const dashboardModel = DashboardModel.create();
const domainsModel = DomainsModel.create();
const inputDialog = inject('inputDialog');
const domains = ref([]);
const formError = ref('');
const originalDomain = ref('');
@@ -64,6 +70,16 @@ async function refreshTasks() {
}
async function onSubmit() {
const confirm = await inputDialog.value.confirm({
title: t('domains.changeDashboardDomain.confirmTitle'),
message: t('domains.changeDashboardDomain.confirmMessage'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!confirm) return;
formError.value = '';
lastTask.value.active = true;

View File

@@ -12,6 +12,7 @@ const dialog = useTemplateRef('dialog');
const formError = ref({});
const busy = ref (false);
const password = ref('');
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
const form = useTemplateRef('form');
const isFormValid = ref(false);
@@ -25,10 +26,19 @@ async function onSubmit() {
busy.value = true;
formError.value = {};
const [error] = await profileModel.disableTwoFA(password.value);
let error;
if (twoFAMethod.value === 'passkey') {
[error] = await profileModel.deletePasskey(password.value);
} else {
[error] = await profileModel.disableTwoFA(password.value);
}
if (error) {
if (error.status === 412) formError.value.password = error.body.message;
else {
if (error.status === 412) {
password.value = '';
formError.value.password = error.body.message;
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
} else {
formError.value.generic = error.status ? error.body.message : 'Internal error';
console.error('Failed to disable 2fa', error);
}
@@ -46,7 +56,8 @@ async function onSubmit() {
}
defineExpose({
async open() {
async open(method = 'totp') {
twoFAMethod.value = method;
password.value = '';
busy.value = false;
formError.value = {};
@@ -60,11 +71,11 @@ 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"
confirm-style="primary"
confirm-style="danger"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
reject-style="secondary"
@@ -78,7 +89,7 @@ defineExpose({
<FormGroup :has-error="formError.password">
<label>{{ $t('profile.disable2FA.password') }}</label>
<PasswordInput v-model="password" required />
<PasswordInput v-model="password" required id="passwordInput" />
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
</FormGroup>
</fieldset>

View File

@@ -75,10 +75,4 @@ onMounted(async () => {
margin-left: 4px;
}
.disks-last-updated {
font-size: 12px;
font-weight: bold;
align-self: center;
}
</style>

View File

@@ -26,6 +26,8 @@ async function getUsage() {
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
if (error) return console.error(error);
showingCachedValue.value = false;
contents.value = [];
eventSource = result;
@@ -36,7 +38,6 @@ async function getUsage() {
if (payload.type === 'done') {
percent.value = 100;
ts.value = Date.now();
showingCachedValue.value = false;
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
contents.value.sort((a, b) => b.usage - a.usage);
@@ -176,7 +177,7 @@ onUnmounted(() => {
.disk-item-title {
display: flex;
justify-content: space-between;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
font-size: 18px;
}
@@ -224,7 +225,7 @@ onUnmounted(() => {
}
tr.highlight {
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
</style>

View File

@@ -104,7 +104,7 @@ onMounted(async () => {
<br/>
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
<template #actions="registry">
<template #actions="{ item:registry }">
<ActionBar :actions="createActionMenu(registry)"/>
</template>
</TableView>

View File

@@ -53,21 +53,13 @@ function needsPort80(dnsProvider, tlsProvider) {
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
}
function setDefaultTlsProvider(p) {
// wildcard LE won't work without automated DNS
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
tlsProvider.value = 'letsencrypt-prod';
} else {
tlsProvider.value = 'letsencrypt-prod-wildcard';
}
}
function resetFields() {
dnsConfig.value.accessKeyId = '';
dnsConfig.value.accessKey = '';
dnsConfig.value.apiUrl = '';
dnsConfig.value.accessToken = '';
dnsConfig.value.apiKey = '';
dnsConfig.value.apikey = '';
dnsConfig.value.appKey = '';
dnsConfig.value.appSecret = '';
dnsConfig.value.apiPassword = '';
dnsConfig.value.apiSecret = '';
@@ -87,8 +79,14 @@ function resetFields() {
}
function onProviderChange(p) {
setDefaultTlsProvider(p);
resetFields(p);
resetFields();
// wildcard LE won't work without automated DNS
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
tlsProvider.value = 'letsencrypt-prod';
} else {
tlsProvider.value = 'letsencrypt-prod-wildcard';
}
}
const gcdnsFileParseError = ref('');
@@ -130,13 +128,23 @@ function onGcdnsFileInputChange(event) {
<div>
<FormGroup>
<label for="providerInput">{{ $t('domains.domainDialog.provider') }} <sup><a href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required @select="onProviderChange"/>
</FormGroup>
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
<!-- powerdns -->
<FormGroup v-if="provider === 'powerdns'">
<label for="powerdnsApiUrlInput">{{ $t('domains.domainDialog.powerdnsApiUrl') }}</label>
<TextInput id="powerdnsApiUrlInput" type="url" v-model="dnsConfig.apiUrl" placeholder="https://ns1.example.com:8081" required />
</FormGroup>
<FormGroup v-if="provider === 'powerdns'">
<label for="powerdnsApiKeyInput">{{ $t('domains.domainDialog.powerdnsApiKey') }}</label>
<MaskedInput id="powerdnsApiKeyInput" v-model="dnsConfig.apiKey" required />
</FormGroup>
<!-- Route53 -->
<FormGroup v-if="provider === 'route53'">
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
@@ -322,8 +330,8 @@ 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>
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
<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>
</div>

View File

@@ -0,0 +1,140 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Button, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
import { startRegistration } from '@simplewebauthn/browser';
import ProfileModel from '../models/ProfileModel.js';
const props = defineProps({
mandatory2FA: { type: Boolean, default: false },
has2FA: { type: Boolean, default: false }
});
const emit = defineEmits([ 'success' ]);
const profileModel = ProfileModel.create();
const dialog = useTemplateRef('dialog');
const setupMode = ref('');
const totpSecret = ref('');
const totpToken = ref('');
const totpQRCode = ref('');
const totpEnableError = ref('');
const passkeyRegisterError = ref('');
const passkeyRegisterBusy = ref(false);
async function onTotpEnable() {
const [error] = await profileModel.enableTotp(totpToken.value);
if (error) {
totpToken.value = '';
return totpEnableError.value = error.body ? error.body.message : 'Internal error';
}
emit('success');
dialog.value.close();
}
async function onRegisterPasskey() {
passkeyRegisterBusy.value = true;
passkeyRegisterError.value = '';
try {
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
if (optionsError) {
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
passkeyRegisterBusy.value = false;
return;
}
const credential = await startRegistration({ optionsJSON: options });
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
if (registerError) {
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
passkeyRegisterBusy.value = false;
return;
}
emit('success');
dialog.value.close();
} catch (error) {
passkeyRegisterError.value = error.message || 'Passkey registration failed';
}
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(method) {
setupMode.value = method || 'passkey';
totpEnableError.value = '';
totpToken.value = '';
passkeyRegisterError.value = '';
dialog.value.open();
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="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>
<!-- 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>
<div style="text-align: center;">
<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 -->
<div v-if="setupMode === 'totp'">
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { 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>
<div style="text-align: center;">
<img :src="totpQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
<small>{{ totpSecret }} <ClipboardAction plain :value="totpSecret"/></small>
</div>
<br/>
<form @submit.prevent="onTotpEnable()">
<input type="submit" style="display: none;" :disabled="!totpToken"/>
<FormGroup style="text-align: left;">
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
<InputGroup>
<TextInput v-model="totpToken" id="totpTokenInput" style="flex-grow: 1;"/>
<Button @click="onTotpEnable()" :disabled="!totpToken">{{ $t('profile.enable2FA.enable') }}</Button>
</InputGroup>
<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>
</template>

View File

@@ -0,0 +1,280 @@
<script setup>
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';
import { eventlogDetails, eventlogSource } from '../utils.js';
const props = defineProps({
fetchPage: { type: Function, required: true },
availableActions: { type: Array, default: () => [] },
app: { type: Object, default: null },
showToolbar: { type: Boolean, default: true },
});
const appsModel = AppsModel.create();
const apps = ref([]);
const eventlogs = ref([]);
const refreshBusy = ref(false);
const page = ref(1);
const perPage = ref(100);
const eventlogContainer = useTemplateRef('eventlogContainer');
const actions = ref([]);
const highlight = useDebouncedRef('', 300);
const currentMatchPosition = ref(-1);
const searching = ref(false);
const SEARCH_LOOKAHEAD_PAGES = 5;
const filterFrom = ref('');
const filterTo = ref('');
const dateFilterPopover = useTemplateRef('dateFilterPopover');
const dateFilterButton = useTemplateRef('dateFilterButton');
function getApp(id) {
return apps.value.find(a => a.id === id);
}
function processEvent(e) {
const app = props.app || (e.data?.appId ? getApp(e.data.appId) : null);
return {
id: Symbol(),
raw: e,
details: eventlogDetails(e, app, props.app?.id || ''),
source: eventlogSource(e, app),
};
}
function isMatch(eventlog, term) {
if (!term) return false;
const t = term.toLowerCase();
if (eventlog.source.toLowerCase().includes(t)) return true;
if (eventlog.details.replace(/<[^>]+>/g, '').toLowerCase().includes(t)) return true;
if (JSON.stringify(eventlog.raw.data).toLowerCase().includes(t)) return true;
if (eventlog.raw.action.toLowerCase().includes(t)) return true;
return false;
}
const matchIndices = computed(() => {
if (!highlight.value) return [];
return eventlogs.value.reduce((acc, e, i) => {
if (isMatch(e, highlight.value)) acc.push(i);
return acc;
}, []);
});
function scrollToIndex(idx) {
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
}
function goToPrevMatch() {
if (currentMatchPosition.value > 0) {
currentMatchPosition.value--;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
}
}
async function goToNextMatch() {
if (!highlight.value || searching.value) return;
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
return;
}
searching.value = true;
let endOfLog = false;
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
const prevLength = eventlogs.value.length;
await fetchMore();
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
searching.value = false;
return;
}
}
searching.value = false;
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
}
function buildFilter() {
const filter = {};
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;
}
async function onRefresh() {
highlight.value = '';
refreshBusy.value = true;
page.value = 1;
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
if (error) return console.error(error);
eventlogs.value = result.map(processEvent);
refreshBusy.value = false;
}
async function fetchMore() {
page.value++;
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
if (error) return console.error(error);
eventlogs.value = eventlogs.value.concat(result.map(processEvent));
}
async function onScroll(event) {
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
}
function onOpenDateFilter(event) {
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
}
watch(actions.value, onRefresh);
watch(filterFrom, onRefresh);
watch(filterTo, onRefresh);
watch(highlight, async () => {
if (matchIndices.value.length > 0) {
currentMatchPosition.value = 0;
await nextTick();
scrollToIndex(matchIndices.value[0]);
} else {
currentMatchPosition.value = -1;
if (highlight.value) goToNextMatch();
}
});
onMounted(async () => {
if (!props.app) {
const [error, result] = await appsModel.list();
if (error) console.error(error);
else apps.value = result;
}
onRefresh();
while (eventlogContainer.value && eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
await fetchMore();
}
});
function setHighlight(value) { highlight.value = value; }
defineExpose({ refresh: onRefresh, setHighlight });
</script>
<template>
<div style="overflow: hidden; display: flex; flex-direction: column; height: 100%;">
<div v-if="showToolbar" style="display: flex; align-items: center; gap: 5px; flex-wrap: wrap; padding-bottom: 10px; justify-content: flex-end;">
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
<MultiSelect v-if="availableActions.length" :search-threshold="10" v-model="actions" :options="availableActions" option-label="label" option-key="id" :selected-label="actions.length ? $t('main.multiselect.selected', { n: actions.length }) : $t('eventlog.filterAllEvents')"/>
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
</div>
<Popover ref="dateFilterPopover" width="300px">
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
<FormGroup>
<label>From</label>
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
</FormGroup>
<FormGroup>
<label>To</label>
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
</FormGroup>
</div>
</Popover>
<div ref="eventlogContainer" class="section-body" style="overflow-y: auto; overflow-x: hidden; flex: 1;" @scroll="onScroll">
<table class="eventlog-table">
<thead>
<tr>
<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>
</thead>
<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>{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
<td class="eventlog-source">{{ eventlog.source }}</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>
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.eventlog-table {
width: 100%;
border-spacing: 0;
table-layout: fixed;
}
.eventlog-table th {
text-align: left;
}
.eventlog-table th,
.eventlog-table td {
padding: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eventlog-table tbody tr.active,
.eventlog-table tbody tr:hover {
background-color: var(--pankow-color-background-hover);
}
.eventlog-source {
font-weight: var(--pankow-font-weight-bold);
}
.eventlog-details {
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
position: relative;
}
.eventlog-details pre {
white-space: pre-wrap;
font-size: 13px;
padding-left: 10px;
margin: 0;
}
.eventlog-table tbody tr.eventlog-match {
background-color: rgba(255, 193, 7, 0.15);
}
.eventlog-table tbody tr.eventlog-match-current {
background-color: rgba(255, 193, 7, 0.35);
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue';
import { RouterView } from 'vue-router';
import { fetcher } from '@cloudron/pankow';
import OfflineOverlay from '../components/OfflineOverlay.vue';
import ProfileModel from '../models/ProfileModel.js';
@@ -9,6 +10,7 @@ const profileModel = ProfileModel.create();
const offlineOverlay = useTemplateRef('offlineOverlay');
const ready = ref(false);
const serviceDown = ref(false);
function onOnline() {
ready.value = true;
@@ -24,6 +26,9 @@ fetcher.globalOptions.errorHook = (error) => {
// re-login will make the code get a new token
if (error.status === 401) return profileModel.logout();
// if sftp addon is down, tell the user
if (error.status === 424) return serviceDown.value = true;
if (error.status === 500 || error.status === 501) {
// actual internal server error, most likely a bug or timeout log to console only to not alert the user
if (!ready.value) {
@@ -48,6 +53,23 @@ onMounted(() => {
<template>
<div style="height: 100%;">
<OfflineOverlay ref="offlineOverlay" @online="onOnline()"/>
<router-view v-if="ready"></router-view>
<div v-if="ready && serviceDown" class="service-down">
<div>
Unable to connect to filemanager service. Check the status and logs in <a href="/#/services">Services view</a>.
</div>
</div>
<router-view v-if="ready" v-show="!serviceDown"></router-view>
</div>
</template>
<style scoped>
.service-down {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>

View File

@@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, computed, useTemplateRef } from 'vue';
import { ref, onMounted, useTemplateRef } from 'vue';
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
import { fetcher, Dialog, DirectoryView, TreeView, TopBar, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
import { sanitize, sleep } from '@cloudron/pankow/utils';
import { API_ORIGIN, BASE_URL, ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
@@ -33,7 +33,6 @@ const extractInProgressDialog = useTemplateRef('extractInProgressDialog');
const busy = ref(true);
const fallbackIcon = ref(`${BASE_URL}mime-types/none.svg`);
const cwd = ref('/');
const busyRefresh = ref(false);
const busyRestart = ref(false);
const fatalError = ref(false);
const activeItem = ref(null);
@@ -68,32 +67,6 @@ const uploadMenuModel = [{
action: onUploadFolder,
}];
const breadcrumbHomeItem = {
label: '/app/data/',
action: () => onActivateBreadcrumb('/'),
};
const breadcrumbItems = computed(() => {
if (!cwd.value) return [];
const parts = cwd.value.split('/').filter((p) => !!p.trim());
const crumbs = [];
parts.forEach((p, i) => {
crumbs.push({
label: p,
action: () => onActivateBreadcrumb('/' + parts.slice(0, i+1).join('/')),
});
});
return crumbs;
});
// watch(() => {
// if (resourceType.value && resourceId.value) router.push(`/home/${resourceType.value}/${resourceId.value}${cwd.value}`);
// loadCwd();
// });
function onFatalError(errorMessage) {
fatalError.value = errorMessage;
fatalErrorDialog.value.open();
@@ -155,14 +128,39 @@ function onSelectionChanged(items) {
selectedItems.value = items;
}
function onActivateBreadcrumb(path) {
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(path)}`);
function onTreeNavigate(event) {
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(event.path)}`);
}
async function onTreeDrop(targetPath, event) {
// check if this is an internal pankow drag (files from DirectoryView)
if (event.dataTransfer.getData('application/x-pankow') === 'selected') {
const files = selectedItems.value;
if (!files || !files.length) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
pasteInProgressDialog.value.open();
try {
await directoryModel.paste(targetPath, 'cut', files);
} catch (e) {
window.pankow.notify({ type: 'danger', text: e, persistent: true });
}
await loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
pasteInProgressDialog.value.close();
}
}
function treeListFiles(path) {
if (!directoryModel) return [];
return directoryModel.listFiles(path);
}
async function onRefresh() {
busyRefresh.value = true;
await loadCwd();
setTimeout(() => { busyRefresh.value = false; }, 500);
}
// either dataTransfer (external drop) or files (internal drag)
@@ -177,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);
}
}
@@ -509,8 +536,8 @@ onMounted(async () => {
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool style="margin-right: 10px"/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
<a v-if="appLink" class="title" :href="appLink" target="_blank">{{ title }}</a>
<span v-else class="title">{{ title }}</span>
</template>
<template #right>
<ButtonGroup>
@@ -521,7 +548,7 @@ onMounted(async () => {
<Button style="margin: 0 20px;" v-tooltip="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<ButtonGroup>
<Button :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
<Button :href="'/terminal.html?id=' + resourceId + '&cwd=/app/data' + cwd" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
<Button :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" v-tooltip="$t('logs.title')" />
</ButtonGroup>
</template>
@@ -529,6 +556,17 @@ onMounted(async () => {
</template>
<template #body>
<div class="main-view">
<div class="main-view-col tree-view-col">
<TreeView
v-if="!busy"
:list-files="treeListFiles"
:active-path="cwd"
:fallback-icon="fallbackIcon"
root-label="/app/data"
:drop-handler="onTreeDrop"
@navigate="onTreeNavigate"
/>
</div>
<div class="main-view-col">
<DirectoryView
class="directory-view"
@@ -548,6 +586,7 @@ onMounted(async () => {
:new-folder-handler="onNewFolder"
:upload-file-handler="onUploadFile"
:upload-folder-handler="onUploadFolder"
:refresh-handler="onRefresh"
:drop-handler="onDrop"
:items="items"
:owners-model="ownersModel"
@@ -556,10 +595,6 @@ onMounted(async () => {
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<div class="side-bar-title">
<a v-show="appLink" :href="appLink" target="_blank" class="no-highlight">{{ title }}</a>
<span v-show="!appLink">{{ title }}</span>
</div>
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
</div>
</div>
@@ -586,17 +621,20 @@ onMounted(async () => {
padding: 0 10px;
}
.side-bar-title {
text-align: center;
font-size: 20px;
margin-bottom: 20px;
}
.main-view-col {
flex-grow: 1;
}
.no-highlight {
.tree-view-col {
flex-grow: 0;
flex-shrink: 0;
width: 250px;
border-right: 1px solid var(--pankow-input-border-color);
overflow: auto;
}
.title {
font-size: 20px;
color: var(--pankow-color-text);
}

View File

@@ -356,7 +356,7 @@ defineExpose({
.footer {
margin-top: 10px;
text-align: center;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
font-size: 12px;
}

View File

@@ -6,16 +6,14 @@ const t = i18n.t;
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
import { marked } from 'marked';
import { eachLimit } from 'async';
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
import NotificationsModel from '../models/NotificationsModel.js';
import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
import ServicesModel from '../models/ServicesModel.js';
import ProfileModel from '../models/ProfileModel.js';
defineProps(['config', 'subscription']);
defineProps(['config', 'notificationCount']);
const profile = inject('profile');
const subscription = inject('subscription');
const helpButton = useTemplateRef('helpButton');
const helpPopover = useTemplateRef('helpPopover');
@@ -27,58 +25,6 @@ function onOpenHelp(popover, event, elem) {
const servicesModel = ServicesModel.create();
const profileModel = ProfileModel.create();
const notificationModel = NotificationsModel.create();
const notificationButton = useTemplateRef('notificationButton');
const notificationPopover = useTemplateRef('notificationPopover');
const notifications = ref([]);
const notificationsAllBusy = ref(false);
function onOpenNotifications(popover, event, elem) {
popover.open(event, elem);
// close after 2 seconds if there is nothing to show
if (notifications.value.length === 0) setTimeout(popover.close, 2000);
}
async function onMarkNotificationRead(notification) {
notification.busy = true;
const [error] = await notificationModel.update(notification.id, true);
if (error) return console.error(error);
await refresh();
// close after 2 seconds if there is nothing to show
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
}
async function onMarkAllNotificationRead() {
notificationsAllBusy.value = true;
await eachLimit(notifications.value, 5, async (notification) => {
notification.busy = true;
const [error] = await notificationModel.update(notification.id, true);
if (error) return console.error(error);
});
await refresh();
notificationsAllBusy.value = false;
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
}
async function refresh() {
const [error, result] = await notificationModel.list();
if (error) return console.error(error);
result.forEach(n => {
n.isCollapsed = true;
n.busy = false;
});
notifications.value = result;
}
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
function onSubscriptionRequired() {
@@ -134,8 +80,6 @@ function onAvatarClick(event) {
}
onMounted(async () => {
if (profile.value.isAtLeastAdmin) await refresh();
await trackPlatformStatus();
});
@@ -150,30 +94,6 @@ onUnmounted(() => {
<InputDialog ref="inputDialog"/>
<Menu ref="avatarMenu" :model="avatarActions" />
<Popover ref="notificationPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
<div v-if="notifications.length" style="overflow: auto; margin-bottom: 10px">
<div class="notification-item" v-for="notification in notifications" :key="notification.id">
<div class="notification-item-title" @click="notification.isCollapsed = !notification.isCollapsed">
<div>
{{ notification.title }}<br/>
<span class="notification-item-date" v-tooltip="prettyLongDate(notification.creationTime)">{{ prettyDate(notification.creationTime) }}</span>
</div>
<Button plain small tool :loading="notification.busy" :disabled="notification.busy" class="notification-read-button" @click.stop="onMarkNotificationRead(notification)">{{ $t('notifications.dismissTooltip') }}</Button>
</div>
<div class="notification-item-body" v-if="!notification.isCollapsed">
<pre v-if="notification.messageJson" style="cursor: auto">{{ JSON.stringify(notification.messageJson, null, 4) }}</pre>
<div v-else style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
</div>
</div>
</div>
<Button v-if="notifications.length" @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
<div v-if="notifications.length === 0" class="notification-item-empty-placeholder">
{{ $t('notifications.allCaughtUp') }}
</div>
</div>
</Popover>
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
<h1 class="help-title">{{ $t('support.help.title') }}</h1>
@@ -196,10 +116,11 @@ 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>
<div class="headerbar-action" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
<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>
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
@@ -249,47 +170,16 @@ onUnmounted(() => {
border-bottom: 1px solid var(--pankow-input-border-color);
}
.notification-item {
margin-bottom: 10px;
padding-bottom: 10px;
cursor: pointer;
border-bottom: 1px solid var(--pankow-input-border-color);
position: relative;
}
.notification-item-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.notification-item-date {
font-size: 10px;
}
.notification-read-button {
visibility: hidden;
margin-right: 10px;
}
.notification-item:hover .notification-read-button {
visibility: visible;
}
@media (hover: none) {
.notification-item .notification-read-button {
visibility: visible;
}
}
.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);
}

View File

@@ -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>

View File

@@ -116,7 +116,7 @@ onMounted(async () => {
<input style="display: none" type="submit"/>
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>

View File

@@ -116,7 +116,7 @@ onMounted(async () => {
<input style="display: none" type="submit" />
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>

View File

@@ -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%;
}

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue';
import { Button } from '@cloudron/pankow';
import { Button, ClipboardAction } from '@cloudron/pankow';
import Section from './Section.vue';
import MailModel from '../models/MailModel.js';
@@ -103,11 +103,11 @@ onMounted(async () => {
<div v-if="key === 'mx' && domain.provider === 'namecheap'">{{ $t('email.dnsStatus.namecheapInfo') }} <sup><a href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
<div v-if="key === 'ptr4' || key === 'ptr6'">{{ $t('email.dnsStatus.ptrInfo') }} <sup><a href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
<div v-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
<div v-else>
<table class="domain-status">
<div v-else style="overflow: hidden;">
<table class="domain-status" style="width: 100%; table-layout: fixed;">
<tbody>
<tr>
<td>{{ $t('email.dnsStatus.hostname') }}:</td>
<td style="width: 160px">{{ $t('email.dnsStatus.hostname') }}:</td>
<td>{{ domainStatus[key].name }}</td>
</tr>
<tr>
@@ -119,12 +119,17 @@ onMounted(async () => {
<td>{{ domainStatus[key].type }}</td>
</tr>
<tr>
<td>{{ $t('email.dnsStatus.expected') }}:</td>
<td>{{ domainStatus[key].expected }}</td>
<td class="domain-status-expected-label">{{ $t('email.dnsStatus.expected') }}:</td>
<td class="domain-status-expected-value">
<div class="domain-status-expected">{{ domainStatus[key].expected }}</div>
<ClipboardAction :value="domainStatus[key].expected"/>
</td>
</tr>
<tr>
<td>{{ $t('email.dnsStatus.current') }}:</td>
<td>{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</td>
<td>
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</div>
</td>
</tr>
</tbody>
</table>
@@ -219,7 +224,7 @@ onMounted(async () => {
overflow: scroll;
white-space: nowrap;
text-overflow: auto;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
.domain-status > tbody > tr > td:first-of-type {
@@ -227,4 +232,19 @@ onMounted(async () => {
padding-right: 20px;
}
.domain-status-expected-label {
vertical-align: top;
}
.domain-status-expected-value {
display: flex;
gap: 6px;
align-items: center;
}
.domain-status-expected {
overflow-wrap: break-word;
word-break: break-all;
}
</style>

View File

@@ -1,21 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { Switch } from '@cloudron/pankow';
import { ref, useTemplateRef } from 'vue';
import { Switch, Dialog } from '@cloudron/pankow';
import SettingsItem from '../components/SettingsItem.vue';
import Section from '../components/Section.vue';
import ProfileModel from '../models/ProfileModel.js';
const profileModel = ProfileModel.create();
const dialogItem = useTemplateRef('dialogItem');
const busy = ref(false);
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,18 +28,22 @@ 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);
if (error) return console.error(error);
dialogItem.value.close();
busy.value = false;
}
onMounted(async () => {
async function open() {
const [error, result] = await profileModel.get();
if (error) return console.error(error);
@@ -47,58 +53,85 @@ onMounted(async () => {
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();
}
defineExpose({
open
});
</script>
<template>
<Section :title="$t('notifications.settings.title')">
<Dialog ref="dialogItem"
:title="$t('notifications.settings.title')"
:confirm-label="$t('main.dialog.save')"
confirm-style="primary"
:confirm-busy="busy"
:confirm-active="!busy"
:reject-label="$t('main.dialog.close')"
reject-style="secondary"
@confirm="onSubmit"
>
<div>{{ $t('notifications.settingsDialog.description') }}</div>
<br/>
<SettingsItem>
<div>{{ $t('notifications.settings.appUp') }}</div>
<Switch v-model="appUpp" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appUpp" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.appDown') }}</div>
<Switch v-model="appDown" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appDown" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.appOutOfMemory') }}</div>
<Switch v-model="appOutOfMemory" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appOutOfMemory" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.backupFailed') }}</div>
<Switch v-model="backupFailed" :disabled="busy" @change="onSubmit()"/>
<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" @change="onSubmit()"/>
<Switch v-model="certificateRenewalFailed" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.diskSpace') }}</div>
<Switch v-model="diskSpace" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="diskSpace" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.cloudronUpdateFailed') }}</div>
<Switch v-model="cloudronUpdateFailed" :disabled="busy" @change="onSubmit()"/>
<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" @change="onSubmit()"/>
<Switch v-model="reboot" :disabled="busy"/>
</SettingsItem>
</Section>
</Dialog>
</template>

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
import ProfileModel from '../models/ProfileModel.js';

View File

@@ -1,9 +1,8 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { marked } from 'marked';
import { Dialog } from '@cloudron/pankow';
import { stripSsoInfo } from '../utils.js';
import { renderSafeMarkdown, stripSsoInfo } from '../utils.js';
const dialog = useTemplateRef('dialog');
const app = ref(null);
@@ -48,13 +47,13 @@ defineExpose({
</div>
</div>
<div v-if="app.manifest.postInstallMessage" v-html="marked.parse(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
<div v-if="app.manifest.postInstallMessage" v-html="renderSafeMarkdown(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
<div class="app-info-checklist" v-show="hasPendingChecklistItems">
<label class="control-label">{{ $t('app.appInfo.checklist') }}</label>
<div v-for="(item, key) in app.checklist" :key="key">
<div class="checklist-item" v-show="!item.acknowledged">
<span v-html="marked.parse(item.message)"></span>
<span v-html="renderSafeMarkdown(item.message)"></span>
</div>
</div>
</div>

View File

@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue';
import { Icon } from '@cloudron/pankow';
const visible = ref(false);
const success = ref(false);
const TIMEOUT = 1500;
let timeoutId;
defineExpose({
success() {
clearTimeout(timeoutId);
success.value = true;
visible.value = true;
timeoutId = setTimeout(() => {
visible.value = false;
}, TIMEOUT);
},
error() {
clearTimeout(timeoutId);
success.value = false;
visible.value = true;
timeoutId = setTimeout(() => {
visible.value = false;
}, TIMEOUT);
}
});
</script>
<template>
<Transition name="bounce">
<div class="save-indicator" v-if="visible" :class="{ success: success, error: !success }"><Icon :icon="success ? 'fa-solid fa-check' : 'fa-solid fa-xmark'"/></div>
</Transition>
</template>
<style scoped>
.save-indicator {
position: absolute;
right: -10px;
}
.save-indicator.success {
color: var(--pankow-color-success);
}
.save-indicator.error {
color: var(--pankow-color-danger);
}
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0);
}
50% {
transform: scale(1.5);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -79,7 +79,6 @@ onUnmounted(() => {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.section-header-title-text {
@@ -106,6 +105,7 @@ onUnmounted(() => {
margin-bottom: 15px;
padding: 10px 15px;
padding-bottom: 25px;
overflow: hidden;
}
.section-header-title-badge {

View File

@@ -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;

View File

@@ -199,7 +199,7 @@ function onBackdrop(event) {
.sidebar-item-header {
background-color: #e9ecef;
display: block;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
color: var(--pankow-text-color);
padding: 10px 15px;
white-space: nowrap;
@@ -224,7 +224,7 @@ function onBackdrop(event) {
.sidebar-item.active {
color: var(--pankow-color-primary);
text-decoration: none;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
.sidebar-item:hover {

View File

@@ -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';

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog, Spinner } from '@cloudron/pankow';
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import { TASK_TYPES } from '../constants.js';
import ActionBar from '../components/ActionBar.vue';
@@ -50,6 +50,12 @@ const columns = {
sort: true,
hideMobile: true,
},
integrity: {
label: 'Integrity',
sort: false,
width: '100px',
align: 'center',
},
actions: {}
};
@@ -66,6 +72,12 @@ function createActionMenu(backup) {
icon: 'fa-solid fa-file-alt',
label: t('backups.listing.tooltipDownloadBackupConfig'),
action: onDownloadConfig.bind(null, backup),
}, {
separator: true,
}, {
icon: 'fa-solid fa-key',
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
}];
}
@@ -151,6 +163,18 @@ async function refreshTasks() {
});
}
const INTEGRITY_POLL_INTERVAL_MS = 5000;
let integrityPollTimer = null;
function scheduleIntegrityPoll() {
if (integrityPollTimer) return;
integrityPollTimer = setTimeout(async () => {
integrityPollTimer = null;
await refreshBackups();
if (backups.value.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
}, INTEGRITY_POLL_INTERVAL_MS);
}
async function refreshBackups() {
const [error, result] = await backupsModel.list();
if (error) return console.error(error);
@@ -161,6 +185,20 @@ async function refreshBackups() {
});
backups.value = result;
if (result.some(b => b.integrityCheckTask?.active)) scheduleIntegrityPoll();
}
async function onStartIntegrityCheck(backup) {
const [error] = await backupsModel.startIntegrityCheck(backup.id);
if (error) return window.cloudron.onError(error);
await refreshBackups();
}
async function onStopIntegrityCheck(backup) {
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
if (error) return window.cloudron.onError(error);
await refreshBackups();
}
async function refreshBackupSites() {
@@ -231,6 +269,10 @@ onMounted(async () => {
await refreshTasks();
});
onUnmounted(() => {
if (integrityPollTimer) clearTimeout(integrityPollTimer);
});
defineExpose({ refresh });
</script>
@@ -272,7 +314,7 @@ defineExpose({ refresh });
</template>
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
<template #creationTime="backup">
<template #creationTime="{ item:backup }">
<div>
<span>{{ prettyLongDate(backup.creationTime) }}</span>
<span v-if="backup.label">&nbsp;<b>{{ backup.label }}</b></span>
@@ -280,18 +322,26 @@ defineExpose({ refresh });
</div>
</template>
<template #content="backup">
<template #content="{ item:backup }">
<span v-if="backup.appCount">{{ $t('backups.listing.appCount', { appCount: backup.appCount }) }}</span>
<span v-else>{{ $t('backups.listing.noApps') }}</span>
</template>
<template #size="backup">
<template #size="{ item:backup }">
<span v-if="backup.stats?.aggregatedUpload">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</span>
</template>
<template #site="backup">{{ backup.site.name }}</template>
<template #site="{ item:backup }">{{ backup.site.name }}</template>
<template #actions="backup">
<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 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>
<template #actions="{ item:backup }">
<ActionBar :actions="createActionMenu(backup)"/>
</template>
</TableView>

View File

@@ -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>

View File

@@ -28,6 +28,7 @@ const showFilemanager = ref(false);
const manifestVersion = ref('');
const schedulerMenuModel = ref([]);
const id = ref('');
const cwd = ref('');
const name = ref('');
const link = ref('');
const downloadFileDownloadUrl = ref('');
@@ -165,7 +166,9 @@ async function connect(retry = false) {
let execId;
try {
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, { access_token: accessToken });
const execBody = { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' };
if (cwd.value) execBody.cwd = cwd.value;
const result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id.value}/exec`, execBody, { access_token: accessToken });
execId = result.body.id;
} catch (error) {
console.error('Cannot create socket.', error);
@@ -216,6 +219,7 @@ onMounted(async () => {
const urlParams = new URLSearchParams(window.location.search);
id.value = urlParams.get('id');
cwd.value = urlParams.get('cwd') || '';
if (!id.value) {
console.error('No app id specified');

View File

@@ -72,7 +72,7 @@ async function onSubmit() {
let userId = user.value ? user.value.id : null;
// can only be set not updated
if (!user.value || !user.value.username) data.username = username.value || null;
if ((!user.value || !user.value.username) && username.value) data.username = username.value;
const isExternal = user.value && user.value.source;
@@ -241,15 +241,15 @@ defineExpose({
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<!-- if profile edit is locked a username has to be set here . username is editable if none is set -->
<FormGroup :has-error="formError.username">
<FormGroup :has-error="!!formError.username">
<label for="usernameInput">{{ $t('users.user.username') }}</label>
<TextInput id="usernameInput" v-model="username" :required="!user?.username && profileLocked" :readonly="user?.username ? true : false" />
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
<div class="error-label" v-if="formError.username">{{ formError.username }}</div>
</FormGroup>
<FormGroup>
<label for="emailInput" :has-error="formError.email">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<FormGroup :has-error="!!formError.email">
<label for="emailInput">{{ $t('users.user.primaryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<EmailInput id="emailInput" v-model="email" :readonly="user?.source ? true : false" :required="user?.source ? false : true" />
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
</FormGroup>

View File

@@ -15,6 +15,8 @@ const domain = ref('');
const matrixHostname = ref('');
const mastodonHostname = ref('');
const jitsiHostname = ref('');
const carddavLocation = ref('');
const caldavLocation = ref('');
async function onSubmit() {
busy.value = true;
@@ -47,6 +49,9 @@ async function onSubmit() {
+ '</XRD>';
}
if (carddavLocation.value) wellKnown['carddav'] = carddavLocation.value;
if (caldavLocation.value) wellKnown['caldav'] = caldavLocation.value;
const [error] = await domainsModel.setWellKnown(domain.value, wellKnown);
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
@@ -66,19 +71,21 @@ defineExpose({
matrixHostname.value = '';
mastodonHostname.value = '';
jitsiHostname.value = '';
caldavLocation.value = '';
carddavLocation.value = '';
try {
if (d.wellKnown && d.wellKnown['matrix/server']) {
matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
}
if (d.wellKnown && d.wellKnown['host-meta']) {
mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
}
if (d.wellKnown && d.wellKnown['matrix/client']) {
const parsed = JSON.parse(d.wellKnown['matrix/client']);
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
if (d.wellKnown) {
if (d.wellKnown['matrix/server']) matrixHostname.value = JSON.parse(d.wellKnown['matrix/server'])['m.server'];
if (d.wellKnown['host-meta']) mastodonHostname.value = d.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
if (d.wellKnown['matrix/client']) {
const parsed = JSON.parse(d.wellKnown['matrix/client']);
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
jitsiHostname.value = parsed['im.vector.riot.jitsi']['preferredDomain'];
}
}
if (d.wellKnown['carddav']) carddavLocation.value = d.wellKnown['carddav'];
if (d.wellKnown['caldav']) caldavLocation.value = d.wellKnown['caldav'];
}
} catch (e) {
console.error(e);
@@ -110,6 +117,16 @@ defineExpose({
<p class="has-error" v-show="errorMessage">{{ errorMessage }}</p>
<FormGroup>
<label for="">{{ $t('domains.domainDialog.carddavLocation') }}</label>
<TextInput id="" v-model="carddavLocation" placeholder="contacts.example.com"/>
</FormGroup>
<FormGroup>
<label for="">{{ $t('domains.domainDialog.caldavLocation') }}</label>
<TextInput id="" v-model="caldavLocation" placeholder="calendar.example.com"/>
</FormGroup>
<FormGroup>
<label for="">{{ $t('domains.domainDialog.matrixHostname') }}</label>
<TextInput id="" v-model="matrixHostname" placeholder="synapse.example.com:443"/>

View File

@@ -56,6 +56,7 @@ onMounted(async () => {
u.username = u.username || u.email; // ensure username
userIds.add(u.id);
}
result.forEach(u => { u.label = u.username || u.email; });
users.value = result;
[error, result] = await groupsModel.list();
@@ -90,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>

View File

@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar } from '@cloudron/pankow';
import { ref, onMounted, onUnmounted, useTemplateRef } from 'vue';
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar, Spinner } from '@cloudron/pankow';
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import { API_ORIGIN, RSTATES } from '../../constants.js';
import { download } from '../../utils.js';
@@ -14,14 +14,13 @@ import AppRestoreDialog from '../AppRestoreDialog.vue';
import SettingsItem from '../SettingsItem.vue';
import AppsModel from '../../models/AppsModel.js';
import BackupSitesModel from '../../models/BackupSitesModel.js';
import TasksModel from '../../models/TasksModel.js';
import { TASK_TYPES } from '../../constants.js';
import BackupsModel from '../../models/BackupsModel.js';
import BackupInfoDialog from '../BackupInfoDialog.vue';
import ActionBar from '../../components/ActionBar.vue';
const appsModel = AppsModel.create();
const backupSitesModel = BackupSitesModel.create();
const tasksModel = TasksModel.create();
const backupsModel = BackupsModel.create();
const props = defineProps([ 'app' ]);
@@ -47,12 +46,21 @@ const columns = ref({
label: t('main.table.version'),
sort: true,
},
integrity: {
label: 'Integrity',
sort: false,
width: '100px',
align: 'center',
},
actions: {
label: '',
sort: false,
width: '100px',
}
});
const accessLevel = props.app.accessLevel;
function createActionMenu(backup) {
return [{
icon: 'fa-solid fa-info',
@@ -61,27 +69,27 @@ function createActionMenu(backup) {
}, {
icon: 'fa-solid fa-pencil-alt',
label: t('main.action.edit'),
visible: props.app.accessLevel === 'admin',
visible: accessLevel === 'admin',
action: onEdit.bind(null, backup),
}, {
separator: true,
}, {
icon: 'fa-solid fa-download',
label: t('app.backups.backups.downloadBackupTooltip'),
visible: backup.site.format === 'tgz' && props.app.accessLevel === 'admin',
visible: backup.site.format === 'tgz' && accessLevel === 'admin',
href: getDownloadLink(backup),
}, {
icon: 'fa-solid fa-file-alt',
label: t('app.backups.backups.downloadConfigTooltip'),
visible: props.app.accessLevel === 'admin',
visible: accessLevel === 'admin',
action: onDownloadConfig.bind(null, backup),
}, {
separator: true,
visible: props.app.accessLevel === 'admin',
visible: accessLevel === 'admin',
}, {
icon: 'fa-solid fa-clone',
label: t('app.backups.backups.cloneTooltip'),
visible: props.app.accessLevel === 'admin',
visible: accessLevel === 'admin',
action: onClone.bind(null, backup),
}, {
icon: 'fa-solid fa-history',
@@ -89,13 +97,13 @@ function createActionMenu(backup) {
disabled: !!props.app.taskId || props.app.runState === 'stopped',
action: onRestore.bind(null, backup),
quickAction: true
// }, {
// separator: true,
// }, {
// icon: 'fa-solid fa-key',
// label: t('app.backups.backups.checkIntegrity'),
// visible: props.app.accessLevel === 'admin',
// action: onCheckIntegrity.bind(null, backup),
}, {
separator: true,
}, {
icon: 'fa-solid fa-key',
label: backup.integrityCheckTask?.active ? t('backups.stopIntegrity') : t('backups.checkIntegrity'),
visible: accessLevel === 'admin',
action: backup.integrityCheckTask?.active ? onStopIntegrityCheck.bind(null, backup) : onStartIntegrityCheck.bind(null, backup),
}];
}
@@ -130,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;
@@ -147,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] || {};
@@ -157,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}`); }
};
});
@@ -177,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();
@@ -196,6 +204,7 @@ function onEdit(backup) {
editLabel.value = backup.label || '';
editError.value = '';
editDialog.value.open();
setTimeout(() => document.getElementById('labelInput').focus(), 500);
}
async function onEditSubmit() {
@@ -232,11 +241,17 @@ async function onRestore(backup) {
restoreDialog.value.open();
}
// const backupsModel = BackupsModel.create();
async function onStartIntegrityCheck(backup) {
const [error] = await backupsModel.startIntegrityCheck(backup.id);
if (error) return window.cloudron.onError(error);
await refreshBackupList();
}
// async function onCheckIntegrity(backup) {
// await backupsModel.checkIntegrity(backup.id);
// }
async function onStopIntegrityCheck(backup) {
const [error] = await backupsModel.stopIntegrityCheck(backup.id);
if (error) return window.cloudron.onError(error);
await refreshBackupList();
}
async function onRestoreSubmit() {
restoreBusy.value = true;
@@ -260,14 +275,32 @@ function onClone(backup) {
cloneDialog.value.open(backup, props.app.id);
}
const INTEGRITY_POLL_INTERVAL_MS = 5000;
let integrityPollTimer = null;
function scheduleIntegrityPoll() {
if (integrityPollTimer) return;
integrityPollTimer = setTimeout(async () => {
integrityPollTimer = null;
await refreshBackupList();
if (backups.value.some(b => b.integrityCheckTask?.active)) {
scheduleIntegrityPoll();
}
}, INTEGRITY_POLL_INTERVAL_MS);
}
async function refreshBackupList() {
const [error, result] = await appsModel.backups(props.app.id);
if (error) return console.error(error);
result.forEach(backup => {
for (const backup of result) {
backup.site = backupSites.value.find(t => t.id === backup.siteId);
});
}
backups.value = result;
if (result.some(b => b.integrityCheckTask?.active)) {
scheduleIntegrityPoll();
}
}
onMounted(async () => {
@@ -290,6 +323,10 @@ onMounted(async () => {
busy.value = false;
});
onUnmounted(() => {
if (integrityPollTimer) clearTimeout(integrityPollTimer);
});
</script>
<template>
@@ -374,7 +411,7 @@ onMounted(async () => {
<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>
@@ -387,23 +424,28 @@ onMounted(async () => {
<br/>
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
<template #creationTime="backup">
<template #creationTime="{ item }">
<div>
<span>{{ prettyLongDate(backup.creationTime) }}</span>
<span v-if="backup.label">&nbsp;<b>{{ backup.label }}</b></span>
<span>&nbsp;<i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
<span>{{ prettyLongDate(item.creationTime) }}</span>
<span v-if="item.label">&nbsp;<b>{{ item.label }}</b></span>
<span>&nbsp;<i class="fa-solid fa-thumbtack text-muted" v-show="item.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
</div>
</template>
<template #site="backup">
{{ backup.site.name }}
<template #site="{ item }">
{{ item.site.name }}
</template>
<template #size="backup">
<span v-if="backup.stats?.upload">{{ prettyFileSize(backup.stats.upload.size) }} - {{ backup.stats.upload.fileCount }} file(s)</span>
<template #size="{ item }">
<span v-if="item.stats?.upload">{{ prettyFileSize(item.stats.upload.size) }} - {{ item.stats.upload.fileCount }} file(s)</span>
</template>
<template #actions="backup">
<div style="text-align: right;">
<ActionBar style="width: 100px" :actions="createActionMenu(backup)"/>
<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 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>
<template #actions="{ item }">
<ActionBar :actions="createActionMenu(item)"/>
</template>
</TableView>
</div>

View File

@@ -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;
}

View File

@@ -1,144 +1,37 @@
<script setup>
import { prettyLongDate } from '@cloudron/pankow/utils';
import { ref, onMounted } from 'vue';
import { eventlogSource, eventlogDetails } from '../../utils.js';
import EventlogList from '../EventlogList.vue';
import AppsModel from '../../models/AppsModel.js';
import { EVENTS } from '../../constants.js';
const appsModel = AppsModel.create();
const props = defineProps([ 'app' ]);
const busy = ref(true);
const eventlogs = ref([]);
const availableActions = [
{ id: EVENTS.APP_BACKUP, label: 'Backup started' },
{ id: EVENTS.APP_BACKUP_FINISH, label: 'Backup finished' },
{ id: EVENTS.APP_CONFIGURE, label: 'Reconfigured' },
{ id: EVENTS.APP_INSTALL, label: 'Installed' },
{ id: EVENTS.APP_RESTORE, label: 'Restored' },
{ id: EVENTS.APP_UNINSTALL, label: 'Uninstalled' },
{ id: EVENTS.APP_UPDATE, label: 'Update started' },
{ id: EVENTS.APP_UPDATE_FINISH, label: 'Update finished' },
{ id: EVENTS.APP_LOGIN, label: 'Log in' },
{ id: EVENTS.APP_OOM, label: 'Out of memory' },
{ id: EVENTS.APP_DOWN, label: 'Down' },
{ id: EVENTS.APP_UP, label: 'Up' },
{ id: EVENTS.APP_START, label: 'Started' },
{ id: EVENTS.APP_STOP, label: 'Stopped' },
{ id: EVENTS.APP_RESTART, label: 'Restarted' },
];
onMounted(async () => {
const [error, result] = await appsModel.getEvents(props.app.id);
if (error) return console.error(error);
eventlogs.value = result.map(e => {
return {
id: Symbol(),
raw: e,
details: eventlogDetails(e, props.app),
source: eventlogSource(e, props.app),
};
});
busy.value = false;
});
async function fetchPage(filter, page, perPage) {
return appsModel.getEvents(props.app.id, filter, page, perPage);
}
</script>
<template>
<div>
<div class="eventlog-list pankow-no-desktop">
<div class="eventlog-list-item" v-for="eventlog in eventlogs" :key="eventlog.id" :class="{ 'active': eventlog.isOpen }">
<div @click="eventlog.isOpen = !eventlog.isOpen" style="display: flex; justify-content: space-between; padding: 0 10px" >
<div style="white-space: nowrap;">
{{ prettyLongDate(eventlog.raw.creationTime) }}
<b style="margin-left: 10px">{{ eventlog.raw.action }}</b>
</div>
<div>{{ eventlog.source }}</div>
</div>
<div v-show="eventlog.isOpen">
<div class="eventlog-details" style="margin-top: 10px; padding-top: 5px">
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
</div>
</div>
</div>
</div>
<table class="eventlog-table pankow-no-mobile">
<thead>
<tr>
<th style="width: 160px">{{ $t('eventlog.time') }}</th>
<th style="width: 15%">{{ $t('eventlog.source') }}</th>
<th style="word-break: break-all; overflow-wrap: anywhere;">{{ $t('eventlog.details') }}</th>
</tr>
</thead>
<tbody>
<template v-for="eventlog in eventlogs" :key="eventlog.id">
<tr @click="eventlog.isOpen = !eventlog.isOpen" :class="{ 'active': eventlog.isOpen }" >
<td style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
<td>{{ eventlog.source }}</td>
<td v-html="eventlog.details"></td>
</tr>
<tr v-show="eventlog.isOpen">
<td colspan="3" class="eventlog-details">
<div v-if="eventlog.raw.source.ip" class="eventlog-source">Source IP: <span @click="onCopySource(eventlog)">{{ eventlog.raw.source.ip }}</span></div>
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<EventlogList :fetch-page="fetchPage" :app="app" :available-actions="availableActions" :show-toolbar="false" />
</template>
<style scoped>
.eventlog-table {
width: 100%;
overflow: auto;
border-spacing: 0px;
}
.eventlog-table th {
text-align: left;
}
.eventlog-table tbody tr {
cursor: pointer;
}
.eventlog-table tbody tr.active,
.eventlog-table tbody tr:hover {
background-color: var(--pankow-color-background-hover);
}
.eventlog-table th,
.eventlog-table td {
padding: 10px 6px;
}
.eventlog-filter {
display: flex;
gap: 5px;
flex-wrap: wrap;
margin: 20px 0;
}
.eventlog-details {
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
cursor: auto;
position: relative;
}
.eventlog-source {
padding-left: 10px;
padding-bottom: 10px;
cursor: copy;
}
.eventlog-details pre {
white-space: pre-wrap;
color: var(--pankow-text-color);
font-size: 13px;
padding-left: 10px;
margin: 0;
border: none;
border-radius: var(--pankow-border-radius);
}
.eventlog-list-item.active {
background-color: var(--pankow-color-background-hover);
}
.eventlog-list-item {
padding: 10px 0;
cursor: pointer;
}
</style>

View File

@@ -3,8 +3,7 @@
import { onMounted, ref, useTemplateRef, inject } from 'vue';
import { Button, ClipboardAction } from '@cloudron/pankow';
import { prettyDate } from '@cloudron/pankow/utils';
import { stripSsoInfo } from '../../utils.js';
import { marked } from 'marked';
import { renderSafeMarkdown, stripSsoInfo } from '../../utils.js';
import AppsModel from '../../models/AppsModel.js';
const appsModel = AppsModel.create();
@@ -32,7 +31,6 @@ async function onAckChecklistItem(item, key) {
hasOldChecklist.value = true;
}
// Notes
async function onSubmit() {
busy.value = true;
@@ -82,6 +80,7 @@ onMounted(() => {
<div class="info-row">
<div class="info-label">{{ $t('app.updates.info.description') }}</div>
<div class="info-value" v-if="app.appStoreId">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
<div class="info-value" v-else-if="app.versionsUrl">{{ app.manifest.title }} {{ app.manifest.upstreamVersion }}</div>
<div class="info-value" v-else>{{ app.manifest.dockerImage }}</div>
</div>
@@ -102,6 +101,13 @@ onMounted(() => {
<div class="info-value" v-else>{{ app.manifest.version }} <ClipboardAction plain :value="app.manifest.version"/></div>
</div>
<div class="info-row" v-if="app.versionsUrl">
<div class="info-label">{{ $t('app.updates.info.packager') }}</div>
<div class="info-value">
<a :href="app.manifest.packagerUrl" target="_blank">{{ app.manifest.packagerName }}</a> (community)
</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('app.updates.info.installedAt') }}</div>
<div class="info-value">{{ prettyDate(app.creationTime) }}</div>
@@ -121,14 +127,14 @@ onMounted(() => {
<div v-for="(item, key) in app.checklist" :key="key">
<div class="checklist-item" v-if="!item.acknowledged">
<span v-html="marked.parse(item.message)"></span>
<span v-html="renderSafeMarkdown(item.message)"></span>
<Button small plain tool style="margin-left: 10px;" @click="onAckChecklistItem(item, key)">{{ $t('main.dialog.done') }}</Button>
</div>
</div>
<div v-for="(item, key) in app.checklist" :key="key" v-show="showDoneChecklist">
<div class="checklist-item checklist-item-acknowledged" v-if="item.acknowledged">
<span v-html="marked.parse(item.message)"></span>
<span v-html="renderSafeMarkdown(item.message)"></span>
<span class="text-muted text-small">{{ item.changedBy }} - {{ prettyDate(item.changedAt) }}</span>
</div>
</div>
@@ -143,7 +149,7 @@ onMounted(() => {
<div>
<div v-show="!editing">
<div v-if="noteContent" v-html="marked.parse(stripSsoInfo(noteContent, app.sso))"></div>
<div v-if="noteContent" v-html="renderSafeMarkdown(stripSsoInfo(noteContent, app.sso))"></div>
<div v-else class="text-muted hand" @click="onEdit()">{{ placeholder }}</div>
</div>
<div v-show="editing">

View File

@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, computed, inject } from 'vue';
import { ref, useTemplateRef, onMounted, computed } from 'vue';
import { Button, SingleSelect, InputGroup, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
import { isValidDomain } from '@cloudron/pankow/utils';
import { ISTATES } from '../../constants.js';
@@ -8,18 +8,17 @@ 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();
const dashboardDomain = inject('dashboardDomain');
const domains = ref([]);
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({});
@@ -55,40 +54,46 @@ function onAddRedirect() {
});
}
const formValid = computed(() => {
if (!domain.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function resetDnsOverwrite() {
needsOverwriteDns.value = [];
overwriteDns.value = false;
errorMessage.value = '';
}
const checkForDomains = [{
domain: domain.value,
subdomain: subdomain.value,
}];
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of aliases.value) {
let subdomain = d.subdomain;
// see apps.js:validateLocations()
if (d.subdomain.startsWith('*')) {
if (subdomain === '*') continue;
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
if (isFormValid.value) {
const checkForDomains = [{ domain: domain.value, subdomain: subdomain.value }];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].subdomain });
for (const d of redirects.value) checkForDomains.push({ domain: d.domain, subdomain: d.subdomain });
for (const d of aliases.value) {
let subdomain = d.subdomain;
// see apps.js:validateLocations()
if (d.subdomain.startsWith('*')) {
if (subdomain === '*') continue;
subdomain = subdomain.replace(/^\*\./, ''); // remove *.
}
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
}
checkForDomains.push({ domain: d.domain, subdomain: subdomain });
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) isFormValid.value = false;
}
if (checkForDomains.find(d => !isValidDomain((d.subdomain ? (d.subdomain + '.') : '') + d.domain))) return false;
return true;
});
}
function onRemoveRedirect(index) {
redirects.value.splice(index, 1);
}
async function onSubmit() {
if (!form.value.reportValidity()) return;
busy.value = true;
errorMessage.value = '';
errorObject.value = {};
needsOverwriteDns.value = false;
needsOverwriteDns.value = [];
const checkForDomains = [{
domain: domain.value,
@@ -99,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) {
@@ -107,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;
@@ -140,6 +149,7 @@ async function onSubmit() {
return console.error(error);
}
await props.refreshApp();
busy.value = false;
}
@@ -190,23 +200,25 @@ onMounted(async () => {
}
else console.error(`Portbinding ${p} not known in manifest!`);
}
setTimeout(checkValidity, 100); // update state of the confirm button
});
</script>
<template>
<div>
<form @submit.prevent="onSubmit()" autocomplete="off" novalidate>
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId || !formValid"/>
<input type="submit" style="display: none;" :disabled="(app.error && app.error.installationState !== ISTATES.PENDING_LOCATION_CHANGE) || app.taskId"/>
<FormGroup>
<label>{{ $t('app.location.location') }}</label>
<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"/>
<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 -->
@@ -218,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"/>
<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>
@@ -232,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>
@@ -251,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>
@@ -270,12 +282,11 @@ onMounted(async () => {
<br/>
<div class="error-label" v-if="errorMessage">{{ errorMessage }}</div>
<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 || !formValid">{{ $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>

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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(() => {
@@ -176,7 +174,7 @@ onMounted(async () => {
<FormGroup v-if="volumeId !== DEFAULT_VOLUME_ID">
<label for="volumePrefixInput">Subdirectory</label>
<TextInput id="volumePrefixInput" placeholder="Prefix within the Volume" v-model="volumePrefix" />
<TextInput id="volumePrefixInput" v-model="volumePrefix" />
</FormGroup>
</fieldset>
</form>
@@ -225,7 +223,7 @@ onMounted(async () => {
</FormGroup>
<br/>
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || (app.error && app.error.installationState !== ISTATES.PENDING_RECREATE_CONTAINER) || !!app.taskId || (!app.error && !mountsChanged) || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
<Button @click="onSubmitMounts()" :loading="mountsBusy" :disabled="mountsBusy || !!app.taskId || !mountsChanged || !mountsValid">{{ $t('app.storage.mounts.saveAction') }}</Button>
</div>
</template>

View File

@@ -7,7 +7,7 @@ const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { APP_TYPES } from '../../constants.js';
import { APP_TYPES, RSTATES, ISTATES } from '../../constants.js';
import AppsModel from '../../models/AppsModel.js';
const appsModel = AppsModel.create();
@@ -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,17 +47,61 @@ 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';
}
const TARGET_RUN_STATE = {
START: Symbol('start'),
STOP: Symbol('stop'),
};
function targetRunState() {
// if we have an error, we want to retry the pending state, otherwise toggle the runstate
if (props.app.error) {
if (props.app.error.installationState === ISTATES.PENDING_START) return TARGET_RUN_STATE.START;
else return TARGET_RUN_STATE.STOP;
} else {
if (props.app.runState === RSTATES.STOPPED) return TARGET_RUN_STATE.START;
else return TARGET_RUN_STATE.STOP;
}
}
const toggleRunStateBusy = ref(false);
async function onStartApp() {
toggleRunStateBusy.value = true;
const [error] = await appsModel.start(props.app.id);
if (error) {
toggleRunStateBusy.value = false;
return console.error(error);
}
setTimeout(() => toggleRunStateBusy.value = false, 3000);
}
async function onStopApp() {
toggleRunStateBusy.value = true;
const [error] = await appsModel.stop(props.app.id);
if (error) {
toggleRunStateBusy.value = false;
return console.error(error);
}
setTimeout(() => toggleRunStateBusy.value = false, 3000);
}
onMounted(async () => {
let [error, result] = await appsModel.backups(props.app.id);
if (error) return console.error(error);
@@ -75,6 +122,22 @@ onMounted(async () => {
<div>
<InputDialog ref="inputDialog" />
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.START">
<label>{{ $t('app.start.title') }}</label>
<div v-html="$t('app.start.description')"></div>
<br/>
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStartApp()">{{ $t('app.start.action') }}</Button>
</div>
<div v-if="app.type !== APP_TYPES.PROXIED && targetRunState() === TARGET_RUN_STATE.STOP">
<label>{{ $t('app.stop.title') }}</label>
<div v-html="$t('app.stop.description')"></div>
<br/>
<Button primary :loading="toggleRunStateBusy" :disabled="app.error || toggleRunStateBusy" @click="onStopApp()">{{ $t('app.stop.action') }}</Button>
</div>
<hr style="margin-top: 20px"/>
<div v-if="app.type !== APP_TYPES.PROXIED">
<label>{{ $t('app.archive.title') }}</label>
<div v-html="$t('app.archive.description')"></div>

View File

@@ -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' ]);
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
@@ -59,6 +57,8 @@ async function onCheck() {
const [error] = await appsModel.checkUpdate(props.app.id);
if (error) return console.error(error);
await props.refreshApp();
busyCheck.value = false;
}
@@ -66,10 +66,17 @@ async function onUpdate() {
busyUpdate.value = true;
updateError.value = '';
const [error, result] = await appsModel.update(props.app.id, props.app.updateInfo.manifest, skipBackup.value);
let appData = '';
if (props.app.appStoreId) {
appData = { manifest: props.app.updateInfo.manifest };
} else if (props.app.versionsUrl) {
appData = { versionsUrl: `${props.app.versionsUrl}@${props.app.updateInfo.manifest.version}` };
}
const [error, result] = await appsModel.update(props.app.id, appData, skipBackup.value);
if (error) {
busyUpdate.value = false;
if (error.status === 400) updateError.value = error.body ? error.body.message : 'Internal error';
if (error.status !== 202) updateError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
@@ -80,6 +87,8 @@ async function onUpdate() {
function onAskUpdate() {
busyUpdate.value = false;
updateError.value = '';
dialog.value.open();
}
@@ -112,26 +121,27 @@ onMounted(async () => {
<div>{{ $t('app.updateDialog.changelogHeader', { version: app.updateInfo.manifest.version }) }}</div>
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
<Checkbox class="skip-backup" v-model="skipBackup" :label="$t('app.updateDialog.skipBackupCheckbox')" />
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
</div>
</Dialog>
<SettingsItem>
<div>
<label>{{ $t('app.updates.auto.title') }}</label>
<div v-if="!app.appStoreId">{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
<div v-else v-html="$t('app.updates.auto.description')"></div>
</div>
<Switch v-if="app.appStoreId" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
<div v-if="app.appStoreId || app.versionsUrl" v-html="$t('app.updates.auto.description')"></div>
<div v-else>{{ $t('app.updates.info.customAppUpdateInfo') }}</div>
</div>
<Switch v-if="app.appStoreId || app.versionsUrl" v-model="autoUpdatesEnabled" :disabled="autoUpdatesEnabledBusy" @change="onAutoUpdatesEnabledChange"/>
</SettingsItem>
<hr style="margin-top: 20px"/>
<div v-if="app.appStoreId">
<div v-if="app.appStoreId || app.versionsUrl">
<label>{{ $t('app.updatesTabTitle') }}</label>
<div v-html="$t('app.updates.updates.description', { appStoreLink: 'https://www.cloudron.io/store/index.html' })"></div>
<div>{{ $t('app.updates.updates.description') }}</div>
</div>
<br/>
<Button v-if="app.appStoreId" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
<Button v-if="app.appStoreId || app.versionsUrl" @click="onCheck()" :disabled="busyCheck" :loading="busyCheck">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
<hr v-if="app.updateInfo" style="margin-top: 20px"/>
@@ -142,7 +152,6 @@ onMounted(async () => {
<div class="changelog" v-html="marked.parse(app.updateInfo.manifest.changelog)"></div>
<div class="error-label" style="margin-top: 12px" v-if="!features.appUpdates">{{ $t('app.updateDialog.subscriptionExpired') }}</div>
<div class="error-label" style="margin-top: 12px" v-if="updateError">{{ updateError }}</div>
<div class="error-label" style="margin-top: 12px" v-if="app.updateInfo.unstable">{{ $t('app.updateDialog.unstableWarning') }}</div>
</div>
<br/>

View File

@@ -180,16 +180,19 @@ const REGIONS_HETZNER = [
{ name: 'Nuremberg (NBG1)', value: 'https://nbg1.your-objectstorage.com' }
];
// https://docs.digitalocean.com/products/platform/availability-matrix/
// https://docs.digitalocean.com/products/spaces/details/availability/
const REGIONS_DIGITALOCEAN = [
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
{ name: 'ATL1', value: 'https://atl1.digitaloceanspaces.com' },
{ name: 'BLR1', value: 'https://blr1.digitaloceanspaces.com' },
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'LON1', value: 'https://lon1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' },
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' }
{ name: 'SYD1', value: 'https://syd1.digitaloceanspaces.com' },
{ name: 'TOR1', value: 'https://tor1.digitaloceanspaces.com' }
];
// https://www.exoscale.com/datacenters/
@@ -290,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' },
@@ -311,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 = [
@@ -335,6 +337,113 @@ const RELAY_PROVIDERS = [
{ provider: 'noop', name: 'Disable outgoing email' },
];
// keep in sync with src/eventlog.js
const EVENTS = Object.freeze({
ACTIVATE: 'cloudron.activate',
PROVISION: 'cloudron.provision',
RESTORE: 'cloudron.restore',
START: 'cloudron.start',
UPDATE: 'cloudron.update',
UPDATE_FINISH: 'cloudron.update.finish',
INSTALL_FINISH: 'cloudron.install.finish',
APP_CLONE: 'app.clone',
APP_CONFIGURE: 'app.configure',
APP_REPAIR: 'app.repair',
APP_INSTALL: 'app.install',
APP_RESTORE: 'app.restore',
APP_IMPORT: 'app.import',
APP_UNINSTALL: 'app.uninstall',
APP_UPDATE: 'app.update',
APP_UPDATE_FINISH: 'app.update.finish',
APP_BACKUP: 'app.backup',
APP_BACKUP_FINISH: 'app.backup.finish',
APP_LOGIN: 'app.login',
APP_OOM: 'app.oom',
APP_UP: 'app.up',
APP_DOWN: 'app.down',
APP_START: 'app.start',
APP_STOP: 'app.stop',
APP_RESTART: 'app.restart',
ARCHIVES_ADD: 'archives.add',
ARCHIVES_DEL: 'archives.del',
BACKUP_FINISH: 'backup.finish',
BACKUP_START: 'backup.start',
BACKUP_CLEANUP_START: 'backup.cleanup.start',
BACKUP_CLEANUP_FINISH: 'backup.cleanup.finish',
BACKUP_INTEGRITY_START: 'backup.integrity.start',
BACKUP_INTEGRITY_FINISH: 'backup.integrity.finish',
BACKUP_SITE_ADD: 'backupsite.add',
BACKUP_SITE_REMOVE: 'backupsite.remove',
BACKUP_SITE_UPDATE: 'backupsite.update',
BRANDING_NAME: 'branding.name',
BRANDING_FOOTER: 'branding.footer',
BRANDING_AVATAR: 'branding.avatar',
CERTIFICATE_NEW: 'certificate.new',
CERTIFICATE_RENEWAL: 'certificate.renew',
CERTIFICATE_CLEANUP: 'certificate.cleanup',
DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',
DIRECTORY_SERVER_CONFIGURE: 'directoryserver.configure',
DOMAIN_ADD: 'domain.add',
DOMAIN_UPDATE: 'domain.update',
DOMAIN_REMOVE: 'domain.remove',
EXTERNAL_LDAP_CONFIGURE: 'externalldap.configure',
GROUP_ADD: 'group.add',
GROUP_REMOVE: 'group.remove',
GROUP_UPDATE: 'group.update',
GROUP_MEMBERSHIP: 'group.membership',
MAIL_LOCATION: 'mail.location',
MAIL_ENABLED: 'mail.enabled',
MAIL_DISABLED: 'mail.disabled',
MAIL_MAILBOX_ADD: 'mail.box.add',
MAIL_MAILBOX_REMOVE: 'mail.box.remove',
MAIL_MAILBOX_UPDATE: 'mail.box.update',
MAIL_LIST_ADD: 'mail.list.add',
MAIL_LIST_REMOVE: 'mail.list.remove',
MAIL_LIST_UPDATE: 'mail.list.update',
REGISTRY_ADD: 'registry.add',
REGISTRY_UPDATE: 'registry.update',
REGISTRY_DEL: 'registry.del',
SERVICE_CONFIGURE: 'service.configure',
SERVICE_REBUILD: 'service.rebuild',
SERVICE_RESTART: 'service.restart',
USER_ADD: 'user.add',
USER_LOGIN: 'user.login',
USER_LOGIN_GHOST: 'user.login.ghost',
USER_LOGOUT: 'user.logout',
USER_REMOVE: 'user.remove',
USER_UPDATE: 'user.update',
USER_TRANSFER: 'user.transfer',
USER_DIRECTORY_PROFILE_CONFIG_UPDATE: 'userdirectory.profileconfig.update',
VOLUME_ADD: 'volume.add',
VOLUME_UPDATE: 'volume.update',
VOLUME_REMOUNT: 'volume.remount',
VOLUME_REMOVE: 'volume.remove',
DYNDNS_UPDATE: 'dyndns.update',
SUPPORT_TICKET: 'support.ticket',
SUPPORT_SSH: 'support.ssh',
PROCESS_CRASH: 'system.crash',
});
// named exports
export {
API_ORIGIN,
@@ -364,6 +473,7 @@ export {
REGIONS_HETZNER,
REGIONS_WASABI,
REGIONS_S3,
EVENTS,
RELAY_PROVIDERS,
};
@@ -396,5 +506,6 @@ export default {
REGIONS_HETZNER,
REGIONS_WASABI,
REGIONS_S3,
EVENTS,
RELAY_PROVIDERS,
};

View File

@@ -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;

View File

@@ -1,6 +1,11 @@
import { createApp } from 'vue';
import '@fontsource/inter';
// import "@fontsource/inter/100.css"; // Specify weight
// import "@fontsource/inter/200.css"; // Specify weight
// import "@fontsource/inter/300.css"; // Specify weight
import "@fontsource/inter/400.css"; // Specify weight
import "@fontsource/inter/500.css"; // Specify weight
// import "@fontsource/inter/600.css"; // Specify weight
import { tooltip, fallbackImage } from '@cloudron/pankow';

View File

@@ -18,10 +18,10 @@ function create() {
if (error || result.status !== 200) return [error || result];
return [null, result.body.appPasswords];
},
async add(identifier, name) {
async add(identifier, name, expiresAt) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name }, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/app_passwords`, { identifier, name, expiresAt }, { access_token: accessToken });
} catch (e) {
error = e;
}

View File

@@ -172,9 +172,41 @@ function create() {
return {
name: 'AppsModel',
getTask,
async install(manifest, config) {
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 = {
appStoreId: manifest.id + '@' + manifest.version,
subdomain: config.subdomain,
domain: config.domain,
secondaryDomains: config.secondaryDomains,
@@ -188,6 +220,13 @@ function create() {
backupId: config.backupId // when restoring from archive
};
// Support both appstore apps (manifest) and community apps (versionsUrl)
if (appData.versionsUrl) {
data.versionsUrl = appData.versionsUrl;
} else if (appData.manifest) {
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
}
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps`, data, { access_token: accessToken });
@@ -307,10 +346,10 @@ function create() {
if (result.status !== 202) return [result];
return [null];
},
async getEvents(id) {
async getEvents(id, filter = {}, page = 1, per_page = 100) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { page: 1, per_page: 100, access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/apps/${id}/eventlog`, { ...filter, page, per_page, access_token: accessToken });
} catch (e) {
return [e];
}
@@ -329,12 +368,18 @@ function create() {
if (result.status !== 200) return [result];
return [null, result.body.update];
},
async update(id, manifest, skipBackup = false) {
async update(id, appData, skipBackup = false) {
const data = {
appStoreId: `${manifest.id}@${manifest.version}`,
skipBackup: !!skipBackup,
};
// Support both appstore apps (manifest) and community apps (versionsUrl)
if (appData.versionsUrl) {
data.versionsUrl = appData.versionsUrl;
} else if (appData.manifest) {
data.appStoreId = `${appData.manifest.id}@${appData.manifest.version}`;
}
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/apps/${id}/update`, data, { access_token: accessToken });

View File

@@ -32,15 +32,26 @@ function create() {
if (error || result.status !== 200) return [error || result];
return [null];
},
async checkIntegrity(id) {
async startIntegrityCheck(id) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/check_integrity`, {}, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/start_integrity_check`, {}, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
if (error || result.status !== 201) return [error || result];
return [null, result.body.taskId];
},
async stopIntegrityCheck(id) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/backups/${id}/stop_integrity_check`, {}, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 204) return [error || result];
return [null];
},
async get(id) {

View File

@@ -0,0 +1,24 @@
import { fetcher } from '@cloudron/pankow';
import { API_ORIGIN } from '../constants.js';
function create() {
const accessToken = localStorage.token;
return {
async getApp(url, version) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/community/app`, { access_token: accessToken, url, version });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body];
}
};
}
export default {
create,
};

View File

@@ -23,6 +23,7 @@ const providers = [
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'PowerDNS', value: 'powerdns' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
@@ -90,6 +91,9 @@ function filterConfigForProvider(provider, config) {
case 'porkbun':
props = ['apikey', 'secretapikey'];
break;
case 'powerdns':
props = ['apiUrl', 'apiKey'];
break;
}
const ret = {

View File

@@ -6,10 +6,10 @@ function create() {
const accessToken = localStorage.token;
return {
async search(actions, search, page, per_page) {
async search(filter, page, per_page) {
let error, result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { actions, search, page, per_page, access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/eventlog`, { ...filter, page, per_page, access_token: accessToken });
} catch (e) {
error = e;
}

View File

@@ -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);

View File

@@ -292,10 +292,10 @@ function create() {
if (result.status !== 202) return [result];
return [null];
},
async eventlog(types, search, page, perPage) {
async eventlog(types, search, page, perPage, from, to) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/mailserver/eventlog`, { page, types, per_page: perPage, search, from, to, access_token: accessToken });
} catch (e) {
return [e];
}

View File

@@ -6,16 +6,30 @@ function create() {
const accessToken = localStorage.token;
return {
async list(domain, search = '') {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page: 1, per_page: 1000, access_token: accessToken });
} catch (e) {
return [e];
async list(domain) {
const perPage = 5000;
let page = 1;
let mailboxes = [];
while (true) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailboxes`, { page, per_page: perPage, access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
mailboxes = mailboxes.concat(result.body.mailboxes);
if (result.body.mailboxes.length < perPage) break;
page++;
}
if (result.status !== 200) return [result];
return [null, result.body.mailboxes];
return [null, mailboxes];
},
async get(domain, name) {
let result;

View File

@@ -6,10 +6,18 @@ function create() {
const accessToken = localStorage.token;
return {
async list(acknowledged = false) {
async list(acknowledged = null, page = 1) {
const query = {
access_token: accessToken,
page,
per_page: 100
};
if (acknowledged !== null) query.acknowledged = !!acknowledged;
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 1000 });
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, query);
} catch (e) {
return [e];
}

View File

@@ -179,10 +179,10 @@ function create() {
return null;
},
async setTwoFASecret() {
async setTotpSecret() {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_secret`, {}, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_secret`, {}, { access_token: accessToken });
} catch (e) {
error = e;
}
@@ -190,10 +190,10 @@ function create() {
if (error || result.status !== 200) return [error || result];
return [null, result.body];
},
async enableTwoFA(totpToken) {
async enableTotp(totpToken) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_enable`, { totpToken }, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_enable`, { totpToken }, { access_token: accessToken });
} catch (e) {
error = e;
}
@@ -204,7 +204,7 @@ function create() {
async disableTwoFA(password) {
let result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/twofactorauthentication_disable`, { password }, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/totp_disable`, { password }, { access_token: accessToken });
} catch (e) {
return [e];
}
@@ -234,6 +234,39 @@ function create() {
if (error || result.status !== 201) return [error || result];
return [null, result.body];
},
async getPasskeyRegistrationOptions() {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register/options`, {}, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 200) return [error || result];
return [null, result.body];
},
async registerPasskey(credential, name) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/register`, { credential, name }, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 201) return [error || result];
return [null, result.body];
},
async deletePasskey(password) {
let error, result;
try {
result = await fetcher.post(`${API_ORIGIN}/api/v1/profile/passkey/disable`, { password }, { access_token: accessToken });
} catch (e) {
error = e;
}
if (error || result.status !== 204) return [error || result];
return [null];
},
};
}

View File

@@ -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;
}

View File

@@ -199,10 +199,10 @@ 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}/twofactorauthentication_disable`, {}, { access_token: accessToken });
result = await fetcher.post(`${API_ORIGIN}/api/v1/users/${id}/totp_disable`, {}, { access_token: accessToken });
} catch (e) {
return [e];
}

View File

@@ -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) {

Some files were not shown because too many files have changed in this diff Show More