Compare commits
771 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfe7bb53e6 | |||
| b2ca6206cc | |||
| 918c2f8587 | |||
| 8f851164d6 | |||
| d215d1998f | |||
| 75e3256497 | |||
| 58f5a17a83 | |||
| e7c3d797be | |||
| 34abd5b8f5 | |||
| 8b138d14bb | |||
| e23abd69b5 | |||
| 9c16ad456d | |||
| 4b851afc6a | |||
| f333148afa | |||
| 8d0160a3e7 | |||
| 4a02e988c1 | |||
| 134472cd4b | |||
| b40a10da7b | |||
| 25f5b33d17 | |||
| f57c39bba2 | |||
| 99b234eca8 | |||
| 9c3c8cc9d1 | |||
| b08e3a5128 | |||
| e48cdc85f7 | |||
| a5da68a7f9 | |||
| 7d594ab0d3 | |||
| 9ed3d668ee | |||
| 0da0a5e027 | |||
| 28eb0b65f4 | |||
| 1d29572ecd | |||
| 07e8d242d1 | |||
| 1586a286d8 | |||
| 4859059eba | |||
| f2949c1836 | |||
| cd6acfb91d | |||
| 2d5dc9a6aa | |||
| 87e7da2aff | |||
| 461eb38d88 | |||
| ba0bb62fa3 | |||
| 1ca62dd38e | |||
| 1b1328c601 | |||
| 9633036887 | |||
| e3d76ea9f4 | |||
| d7212e69b5 | |||
| ead58bd6f6 | |||
| fbe13b75df | |||
| 6085a8231f | |||
| e15cd190b3 | |||
| 3d55423deb | |||
| f62df52c1d | |||
| 7829f94ac4 | |||
| e9d42b9cdd | |||
| 1f05a8d92a | |||
| 69ae2b2997 | |||
| b86e47de02 | |||
| ea7647f43c | |||
| ae7df52780 | |||
| bc5737b9b0 | |||
| d0745d1914 | |||
| 2b4c926a70 | |||
| d922c1c80f | |||
| 67500a7689 | |||
| 1c8aa7440c | |||
| d128dbec4c | |||
| 676cb8810b | |||
| 189e3d5599 | |||
| 009d0b39f9 | |||
| 81a8aa7c3d | |||
| 6c6761d14b | |||
| 7d2e3df929 | |||
| f334c696cb | |||
| db974d72d5 | |||
| c15e342bb8 | |||
| dc1449c7b6 | |||
| 0b305caf58 | |||
| 8f1f3645b2 | |||
| 0079162efe | |||
| 7afec06d4c | |||
| 29f85a8fd2 | |||
| 6e0dc24eca | |||
| cee1180aa7 | |||
| 6db2b55e63 | |||
| a3c038781f | |||
| 59c9e5397e | |||
| a4c253b9a9 | |||
| f12b4faf34 | |||
| ff49759f42 | |||
| 01d0c738bc | |||
| d57554a48c | |||
| b16b57f38b | |||
| 12177446a2 | |||
| 61b15db958 | |||
| 349e8f5139 | |||
| f30482808b | |||
| 79cdecdff6 | |||
| 336dee53cd | |||
| 77022bbd7f | |||
| df96df776d | |||
| 67bc803859 | |||
| 8ef56c6d91 | |||
| d377d1e1cf | |||
| 4209e4d90d | |||
| 83c85d02ee | |||
| 866b72d029 | |||
| 4bc0f44789 | |||
| 99c55cb22f | |||
| 74c73c695f | |||
| b972891337 | |||
| 57515d54db | |||
| 0ff8dcc8e9 | |||
| 38efa6a2ba | |||
| 6306625184 | |||
| 1803ab303f | |||
| e72dd7c845 | |||
| 87288caeb9 | |||
| 79b519e462 | |||
| 5f8ea2aecc | |||
| 94bc52a0c3 | |||
| fed51bdcd9 | |||
| 5fc9689645 | |||
| 7c6a783fc8 | |||
| 764d479d7f | |||
| ec15f29e40 | |||
| 2c12bee79b | |||
| 1120866b75 | |||
| b362c069e5 | |||
| 4b6b18c182 | |||
| 80efc8c60c | |||
| 99168157fc | |||
| 23c3263562 | |||
| 1179a78fe1 | |||
| 82677ddd85 | |||
| 31f29e9086 | |||
| 3b3e606573 | |||
| 18b713cec3 | |||
| 2a6d385cea | |||
| 4cc1926899 | |||
| 15b2c2b739 | |||
| 197bf56271 | |||
| 4110f4b8ce | |||
| becbaca858 | |||
| add50257f6 | |||
| f061ee5f88 | |||
| 480b81b3dd | |||
| 61d4a795ae | |||
| cd89883dbb | |||
| d5a729a2ba | |||
| b41533c278 | |||
| 04758587b4 | |||
| b6b0969879 | |||
| 18ef97fae6 | |||
| 333f052f86 | |||
| 7dd40eccf3 | |||
| db728840a0 | |||
| 8906436824 | |||
| e8dedb04a5 | |||
| d4b581c007 | |||
| a900beb3fd | |||
| 19a0f77c53 | |||
| 6dbd97ba14 | |||
| d2fbea8e39 | |||
| 86a49c6223 | |||
| e97f9b47d7 | |||
| ee3ed5f660 | |||
| 3446f3d1e0 | |||
| 69d03a7a42 | |||
| bab95cbefa | |||
| 5ef23fa49a | |||
| f4ff63485a | |||
| c20fbe8635 | |||
| 662cf65ff2 | |||
| 7ded517b20 | |||
| 4be31b0dad | |||
| dc439ba5be | |||
| 5ba8a05450 | |||
| 7ef19b318a | |||
| 2ac76ad852 | |||
| f4598f81c9 | |||
| c432dbb5bc | |||
| d0f0bb799e | |||
| a98dbfdf4f | |||
| a71909acd3 | |||
| ea5953a397 | |||
| 4ad9ccabe0 | |||
| 17640d44fa | |||
| 812d471573 | |||
| fa981d5a83 | |||
| 202f2c6cb0 | |||
| 55359bfa24 | |||
| 95fcfce9cd | |||
| 3120a2c43f | |||
| 7ba3a59dea | |||
| eb5f8fcfa1 | |||
| 5014227028 | |||
| 7a76de2e4c | |||
| de5692c1af | |||
| 555e4f0e65 | |||
| 723c670100 | |||
| 2f951dc272 | |||
| 0daabdc21c | |||
| 38a187e9fc | |||
| 5a613231e0 | |||
| 28a35e7260 | |||
| 461a5a780d | |||
| 207260821b | |||
| 466527884f | |||
| 9d03eb2643 | |||
| c801202642 | |||
| 95952fae75 | |||
| f62629b513 | |||
| f04087815c | |||
| 255b1c63d0 | |||
| 9b5b8ddc22 | |||
| d0a66f1701 | |||
| c176ac600b | |||
| cf0ab16533 | |||
| 03d0e2157e | |||
| cdd5137ebe | |||
| 0a924b2c29 | |||
| 43acecfc6e | |||
| 5e7e739589 | |||
| 0b968b6a98 | |||
| f14dfb6c17 | |||
| cb5ccd8166 | |||
| bfbcbb686d | |||
| 744300744c | |||
| 9bac099339 | |||
| 135c9fb64d | |||
| 4ed6fbbd74 | |||
| 4d3e9dc49b | |||
| 319360f8d0 | |||
| 3ef990b0bf | |||
| b8ae46b6df | |||
| 113aba0897 | |||
| a51672f3ee | |||
| f08b3eb006 | |||
| 66f65093fc | |||
| d78944e03b | |||
| 2fe31b876f | |||
| 9949ea364a | |||
| 77b7f7bfad | |||
| 8d4b458a22 | |||
| 2df8e77733 | |||
| c21011a17a | |||
| a11a691788 | |||
| 81659d4bf2 | |||
| aab20fd23e | |||
| 5fad4dd034 | |||
| 7bc19e8185 | |||
| 45d0928ff9 | |||
| 9b768273f4 | |||
| ef24b17a70 | |||
| dfbe5aaa16 | |||
| f499c9ada9 | |||
| c1a73aa62a | |||
| 601e787500 | |||
| d24bfabdc1 | |||
| 2c559d63f5 | |||
| b5a1554631 | |||
| 510e1c7296 | |||
| c6d8af5dc3 | |||
| adf884c2c4 | |||
| c7b321315c | |||
| 9f2eefcbb3 | |||
| fc2e39f41b | |||
| eae86d15ef | |||
| 361d80da17 | |||
| 2597402496 | |||
| c8bc6f9ffe | |||
| b0ef9238ff | |||
| b71e503a01 | |||
| e9f96593c3 | |||
| 36aa641cb9 | |||
| ddb46646fa | |||
| 96dc79cfe6 | |||
| e0e9f14a5e | |||
| b24e1142f8 | |||
| 0543b16de9 | |||
| 8d46c09f95 | |||
| 5724ca73b4 | |||
| 3e09bef613 | |||
| 627b1fe33f | |||
| 1aa270485c | |||
| ae09c19b69 | |||
| c5cf8eef1a | |||
| e76d4b3474 | |||
| 88a44ee065 | |||
| 51e02da277 | |||
| e9c3e42aa6 | |||
| 93a0063941 | |||
| 26a3cf79c5 | |||
| 26999afc22 | |||
| 81729e4b2a | |||
| 4bae5ee2fb | |||
| a786e6c8f5 | |||
| 3803f36aa5 | |||
| 55bc26bd09 | |||
| d84037a0dd | |||
| 1ce5fcafd9 | |||
| 281233f48b | |||
| 68d73e088d | |||
| b433191b35 | |||
| d75ad44315 | |||
| c3d3c3a6e9 | |||
| b9b8ccb8ae | |||
| 5a56a7c8af | |||
| d4efb63f3d | |||
| 2ec349e919 | |||
| 772770273a | |||
| fa5cbfc304 | |||
| 5276321ade | |||
| 6303602323 | |||
| 486fb0d10a | |||
| 74b8a08251 | |||
| 2a244bb8d4 | |||
| 84e73943f7 | |||
| ace09ca5a7 | |||
| a9ae34b149 | |||
| cff778fe6a | |||
| be69f9f8a3 | |||
| 5ca2078461 | |||
| 4461e7225f | |||
| 49d5d10d77 | |||
| 84374f03e9 | |||
| f8a44014f7 | |||
| 6befb64691 | |||
| 1ff2c21c61 | |||
| c79d4a24c4 | |||
| 3d7a5676d8 | |||
| aa362477e8 | |||
| 13b524e8a5 | |||
| d6eb6d3e3e | |||
| 91b8f1a457 | |||
| c8cdcfc99f | |||
| fe20e738cd | |||
| e23856bf10 | |||
| a7de7fb286 | |||
| a931d2a91f | |||
| c94c66b71e | |||
| cb89c30591 | |||
| 1012c0f654 | |||
| 9b5fb9ae8f | |||
| c4055271a8 | |||
| cd1df37ed3 | |||
| 3d8d4fd921 | |||
| 17b0c3e48d | |||
| f364257db9 | |||
| 6b0d9f8551 | |||
| f0fb420a8d | |||
| 8aa2695263 | |||
| b9af8ee6be | |||
| 7077289840 | |||
| bdd35fb02a | |||
| 47660c5679 | |||
| 28573f9676 | |||
| 375b7f6dd7 | |||
| 99ec2d5ce7 | |||
| f2afd654f8 | |||
| d42919285b | |||
| 33a1f135e0 | |||
| 214b836d13 | |||
| 408a07e8b9 | |||
| b247731062 | |||
| dcaa484929 | |||
| 35886633e5 | |||
| d04afc26e7 | |||
| bb5f1b703e | |||
| dfe2d27709 | |||
| 1dec4f0070 | |||
| 89b6513217 | |||
| 16a8caa8db | |||
| 1594d190eb | |||
| 3333f70a64 | |||
| 5bd803e6b4 | |||
| b5f5b096d4 | |||
| dce05140bf | |||
| 29d5ac94b2 | |||
| 2b80c6c1ad | |||
| 94a62b040b | |||
| 2bb9c50db9 | |||
| 6533ba4581 | |||
| 081909572e | |||
| aa84cb0079 | |||
| a66c3700b3 | |||
| 70476bd168 | |||
| a7929e142f | |||
| fd0d65b8ce | |||
| ef2a94c2c8 | |||
| b43daf2f08 | |||
| 280f628746 | |||
| 713774c03f | |||
| 0889c1531e | |||
| dbd5810a08 | |||
| c8722e9945 | |||
| 87780a2fc8 | |||
| ab03256db0 | |||
| e26640c80e | |||
| e6806453e1 | |||
| d0fb2583a5 | |||
| c4f8f318af | |||
| a6286bb67e | |||
| 90aea9708c | |||
| cb076123b3 | |||
| 70a9a66ae9 | |||
| 8521a47cfa | |||
| 106cc5238e | |||
| 2040eb22a2 | |||
| b6075a9765 | |||
| daacbcb89d | |||
| 6d622bbd14 | |||
| f355da4874 | |||
| 4b36de5200 | |||
| 88d37e99aa | |||
| 1608fc3fdc | |||
| 057fd18139 | |||
| b6371a0bdf | |||
| 03fe72e0b1 | |||
| 3bf4bddc10 | |||
| 92dcf19511 | |||
| b238443a9d | |||
| 021a39a964 | |||
| 72c494e9dc | |||
| 42cefd56eb | |||
| 944f163882 | |||
| 11a8a73723 | |||
| e34cf8f6a6 | |||
| 7f8143f06f | |||
| 472e513a9f | |||
| 1cbacab3a2 | |||
| 49bbb8588d | |||
| 23e0fe5791 | |||
| 6877dfb772 | |||
| f65b33f3fc | |||
| 3daddf2fe6 | |||
| efccf2729b | |||
| 3a1cd8f67f | |||
| 53c90429d3 | |||
| 7b5384a7d5 | |||
| 2b362d8eaf | |||
| ce0024a43c | |||
| 888696975d | |||
| da5852d330 | |||
| 81fa8544dd | |||
| e407286c39 | |||
| 908f7b8985 | |||
| 98edbcaeb2 | |||
| 482b7e8017 | |||
| acf295a259 | |||
| a0667da4de | |||
| f95ad86d5b | |||
| 72f03c75c8 | |||
| 14cb8f0014 | |||
| 0d57870311 | |||
| fb6fca152f | |||
| 11a33455ce | |||
| 124076ed72 | |||
| 294f591152 | |||
| f9414dc815 | |||
| 99c1e0e262 | |||
| f6c344873d | |||
| f8f768337e | |||
| c64694e40f | |||
| 116791f29f | |||
| 69fd7e0b7d | |||
| ac539d1f90 | |||
| 9774a17f7e | |||
| 1f7b0c076c | |||
| 51c6c37ea6 | |||
| 790de8cfa6 | |||
| f49f2ecb6c | |||
| 9647fb358b | |||
| e9e28ae26a | |||
| 60032c186d | |||
| e7011ca0a5 | |||
| 0382113567 | |||
| 18fe633979 | |||
| 2d8b4d9c2a | |||
| d4d6050862 | |||
| 6bed5265e2 | |||
| a1b4fdf624 | |||
| b9ea1573ea | |||
| 7a56545e9e | |||
| 2bf9b66af7 | |||
| 215a6faae9 | |||
| 61f37e0260 | |||
| b2c434a1fd | |||
| 0d2bcbf25b | |||
| a3d1838a8c | |||
| 692fb1a68c | |||
| c71d915a4b | |||
| a0b5dec8b9 | |||
| e2f71b10ec | |||
| 743e4fce0b | |||
| d97c608323 | |||
| 89baa3cabf | |||
| d83712b093 | |||
| 806309fc33 | |||
| 70f6343a2c | |||
| 03dca869c8 | |||
| 84a10d4eb1 | |||
| 554a77fbca | |||
| e12f5e41ff | |||
| 79ad003bc6 | |||
| fc417022c9 | |||
| f427d9f1c4 | |||
| 409f185f7e | |||
| 6b080455ff | |||
| da726ecd15 | |||
| a8f61878ca | |||
| 73e929f0cf | |||
| 60420c3e32 | |||
| a02e933375 | |||
| 73df6519f0 | |||
| ac3a34ff58 | |||
| 8d85b521c8 | |||
| 6d89010a1f | |||
| 8c85fdd7b5 | |||
| cc535b0d0a | |||
| d275b56dc1 | |||
| ad1fc9b9c7 | |||
| 1ea6fb9300 | |||
| 9d96ab8f6a | |||
| 4f518d2315 | |||
| 7377476f97 | |||
| a55bd4458c | |||
| 22cb7f7d8f | |||
| 7b46595503 | |||
| aa30f6ef98 | |||
| 5107cd28c4 | |||
| b537d73a55 | |||
| 9a5c49bd08 | |||
| 19cf204dc4 | |||
| a75baba1f6 | |||
| a2dd45fd69 | |||
| b90cdb8686 | |||
| 16e79c6546 | |||
| f3fbff291f | |||
| f994088d38 | |||
| 091a49ff78 | |||
| 357313b555 | |||
| 3b64d8b0a5 | |||
| 6fa95d9f4f | |||
| 15ff5ede7e | |||
| d89c826e18 | |||
| 5e485fb87e | |||
| 6b7e8bef1d | |||
| 5cb2312806 | |||
| aa7543ad0c | |||
| b6df80dcef | |||
| c0ad75cc4d | |||
| 612002ec33 | |||
| bb96b96e24 | |||
| 49fc63d422 | |||
| 350315fa56 | |||
| fa859a3b5d | |||
| b2f5110871 | |||
| 18d0cae6b0 | |||
| f09b03338e | |||
| 6e011ae70e | |||
| 854fbe53be | |||
| 1ef252fbc2 | |||
| aaebe01892 | |||
| 83efffb7f9 | |||
| b89aa4488c | |||
| 2029148e7c | |||
| 8b33414c55 | |||
| 0e177a7a4c | |||
| 11fc6a61d5 | |||
| ca5ab6edf5 | |||
| bbefca71e5 | |||
| 001adcee62 | |||
| 4870cdd76f | |||
| 3dc8e87a27 | |||
| 1cd069df5e | |||
| 4dd1a960c1 | |||
| 2c8dc3e6a7 | |||
| 49f8b3b7f6 | |||
| dd9dc34308 | |||
| a8b41945d0 | |||
| fa776c34de | |||
| a3a4bbbb83 | |||
| 52e1276c8d | |||
| 241be5eaee | |||
| a32903218e | |||
| 6620fc8570 | |||
| 388a4d93e4 | |||
| 85898d3531 | |||
| 1f2e1691f9 | |||
| 2693f5f496 | |||
| 854f7d7f2e | |||
| 1cac67d4c5 | |||
| 72970720d2 | |||
| b5c75caea0 | |||
| f421fd771f | |||
| 748f3a3a4f | |||
| 59ccf6181e | |||
| c7f5e6b5b0 | |||
| 10f99673c5 | |||
| aff5e8f44d | |||
| 7db5a48e35 | |||
| fe73e76fe9 | |||
| faa22feebf | |||
| 9773c02e7d | |||
| 628902bb70 | |||
| c2e981b35a | |||
| 2f40eeb49f | |||
| cfb2501576 | |||
| 4057906b2c | |||
| 93fe97b94d | |||
| aa2df465a0 | |||
| 350438b2c4 | |||
| 075499b695 | |||
| b361adbe30 | |||
| c448322367 | |||
| b6d4b58f86 | |||
| bbb00ff36f | |||
| 07dc823528 | |||
| b9ae97e5ec | |||
| dfafbdd882 | |||
| 35d0227862 | |||
| c8842cc71f | |||
| 620974217a | |||
| 392d47852d | |||
| f714cd66f7 | |||
| 425e196dfc | |||
| 1ffe617287 | |||
| ea93d197ab | |||
| 37c569a976 | |||
| 7a189bd5e5 | |||
| d3876eb7b0 | |||
| 64cb848a37 | |||
| 162e51a0af | |||
| 59b9991a2c | |||
| 97128673ff | |||
| fdac444aed | |||
| c656903772 | |||
| 61b5ab8a49 | |||
| 550df1be89 | |||
| 99c14533a5 | |||
| b759fdb6e3 | |||
| 374e1f65c6 | |||
| 3d6526de3e | |||
| 8f43c7d3d8 | |||
| e5b7ad5be2 | |||
| 8227ce1158 | |||
| 35b80178ed | |||
| 80b0dba9fe | |||
| a5497dc215 | |||
| 964fb5d251 | |||
| e24ee05337 | |||
| c6858d505f | |||
| 0ea1e47176 | |||
| 5355b91f37 | |||
| 86e7eb1087 | |||
| 043d89c03b | |||
| 1cbad1057d | |||
| d906771b18 | |||
| 76ef9c0388 | |||
| 262d96f8d7 | |||
| 41b7466325 | |||
| 76f2c5f9fc | |||
| e5a1fc9e2d | |||
| 11f9e260ed | |||
| e209bdec65 | |||
| 6432851a78 | |||
| 31fb22a7c3 | |||
| bc47e30ad3 | |||
| 58cf7c720f | |||
| 48bf73de80 | |||
| 76a3f4e86c | |||
| 3a760282f1 | |||
| 71affc0239 | |||
| 3b95d23d23 | |||
| 8cd5345f8c | |||
| fda393b5e1 | |||
| 264f9f84ed | |||
| 1d73760901 | |||
| 03a13df47b | |||
| 5160f22d91 | |||
| 3bbc2bf986 | |||
| 90f68da42f | |||
| f37438b7a7 | |||
| 826d124a5f | |||
| c162fd178b | |||
| 9b92e48a6e | |||
| 5b5c15b7f3 | |||
| 6e9cd4c11b | |||
| 8c03c73b28 | |||
| 2c10ceba5b | |||
| 2a3110cd3d | |||
| 924ea435b1 | |||
| 0e4a389910 | |||
| 720dc14ecf | |||
| 51f5f0b82d | |||
| f380a6f8cf | |||
| 437a033739 | |||
| 2b77e4d292 | |||
| 0e104ee936 | |||
| a820bf7bd0 | |||
| 09fdec8fbd | |||
| 80f6d733b9 | |||
| 838345ba46 | |||
| c2378d33b4 | |||
| 95575bc040 | |||
| 2926871eab | |||
| 5b05ea285c | |||
| 48a2e6881f | |||
| edbeaa2f77 | |||
| 48a85a620d | |||
| cc8db71ecf | |||
| e4573f74a4 | |||
| 8cff72cf59 | |||
| 73a9de7708 | |||
| 104318ab8c | |||
| 8ec4659949 | |||
| ffa8ff8427 | |||
| 4ef1339ba2 | |||
| 3702efdcb3 | |||
| bbdfbe1ab7 | |||
| cc1fc5c269 | |||
| bc32fa64bf | |||
| cfc7de9c77 | |||
| 945ab30373 | |||
| 494125227f | |||
| a4919b06f9 | |||
| 790ba406bf | |||
| e0367056bd | |||
| 4bf0dc192c | |||
| 4575a0ddce | |||
| 837cbff092 | |||
| 4108047644 | |||
| 347cf4f67d | |||
| 7f9344a556 | |||
| 8907b692c1 | |||
| 6c0d5cb601 | |||
| 5c69a146f6 | |||
| de75ae5b9e | |||
| 9c9e2c6a62 | |||
| 917c18a423 | |||
| aac81c2fba | |||
| 9e82839fb7 | |||
| ae2f74777b | |||
| 4c5d67606f | |||
| 0d2a0f91c7 | |||
| b65fa3e2c7 | |||
| e87d2e1218 | |||
| 00ae320b51 | |||
| 3d46d24038 | |||
| 8b04484ff7 | |||
| 7f9f3f683b | |||
| fb2ce06621 | |||
| 89f5e87601 | |||
| e124755363 | |||
| d0ccbe2786 | |||
| 25dec602b8 | |||
| bbf7007250 | |||
| 2b4f8ff00d | |||
| b467b58ee7 | |||
| facefeddae | |||
| 141bdb1307 | |||
| b53da61e7c | |||
| ede93323af | |||
| 8ccf79175a | |||
| 9fa330a0a0 | |||
| 3693857960 | |||
| c5f97e8bb0 | |||
| 2cb7b4d1ea | |||
| 6247cece94 | |||
| 417f5c3610 | |||
| 3e6f3bd807 | |||
| 6346c7fe9b |
@@ -4,3 +4,5 @@ installer/src/certs/server.key
|
||||
# vim swap files
|
||||
*.swp
|
||||
|
||||
.cursor
|
||||
|
||||
|
||||
@@ -3062,3 +3062,160 @@
|
||||
* access control: fix spacing
|
||||
* storage: pass limits object to backend
|
||||
|
||||
[9.0.11]
|
||||
* mail: fix count indicator when loading
|
||||
* mailinglist: fix search on name
|
||||
* backup site: fix migration with mixed formats
|
||||
|
||||
[9.0.12]
|
||||
* eventlog: always fetch enough event logs to fill the screen
|
||||
* mail: check for outbound ipv6 connectivity
|
||||
* store actual appId not oidc clientId for log in events
|
||||
* Add english labels for eventlog filtering
|
||||
* mail: when deferred, show reason
|
||||
* mail: prefer ipv4 for outbound mail
|
||||
|
||||
[9.0.13]
|
||||
* Fix issue where footer/name can break templates
|
||||
* rsync: bump empty dir limit to 80k
|
||||
* nginx: do not log query params
|
||||
* Fetch mailbox usage in the background to not delay mailbox listing
|
||||
* cloudron-support: add --check-services and add it to troubleshoot
|
||||
* Do not poll services if they are in recoveryMode
|
||||
* restore/import: fix issue where prefix was empty
|
||||
|
||||
[9.0.14]
|
||||
* Also use a temporary SSH identity file for optimized ssh remote rm -rf
|
||||
* app search: title is optional manifest
|
||||
* network: detect default ipv6 interface when no ipv4 interface
|
||||
* mail status: fix rbl display
|
||||
* platform: show any container upgrade errors in the UI
|
||||
* users: make remove 2fa separate dialog
|
||||
* mandatory 2fa: show undismissable dialog and warning
|
||||
* restore: validate ipv6 config
|
||||
* location: use the domain where app is installed as default
|
||||
* s3: remove leading slash in CopySource
|
||||
* gcs: fix copy operation
|
||||
* restore: fix crash when trying to mount fs volumes
|
||||
* restore: teardown pseudo backup site
|
||||
* oidc: add separate jwks key route for cloudflare access
|
||||
|
||||
[9.0.15]
|
||||
* sshfs: Use unique temporary ssh key file for each ssh remote operation
|
||||
|
||||
[9.0.16]
|
||||
* Update mongodb to 7.0.28 (also fixes mongobleed)
|
||||
* 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.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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -54,50 +56,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.' }));
|
||||
|
||||
+51
-11
@@ -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>
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>OpenID Device Success</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = <%- JSON.stringify({ name }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/oidcdevicesuccess.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,12 +4,7 @@
|
||||
<title><%= name %> OpenID Error</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.errorMessage = `<%- errorMessage %>`;
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({ iconUrl, name, errorMessage, footer, language }) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> OpenID Access Denied</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
submitUrl: submitUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.note = '<%- note %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
|
||||
passkeyLoginUrl: passkeyLoginUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
Generated
+1671
-2618
File diff suppressed because it is too large
Load Diff
+17
-16
@@ -7,26 +7,27 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.9",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"@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.5.1",
|
||||
"marked": "^17.0.0",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"marked": "^17.0.5",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.2.2",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.24",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.6.3"
|
||||
"moment-timezone": "^0.6.1",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vue": "^3.5.31",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^5.0.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Password Reset</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.loginUrl = '<%- loginUrl %>';
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron.apiOrigin = `<%= apiOrigin %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
iconUrl: iconUrl,
|
||||
loginUrl: loginUrl,
|
||||
language: language,
|
||||
apiOrigin: apiOrigin
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,9 +36,6 @@
|
||||
"username": "Brugernavn",
|
||||
"displayName": "Vis navn",
|
||||
"actions": "Foranstaltninger",
|
||||
"table": {
|
||||
"date": "Dato"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Genstart",
|
||||
"logs": "Logfiler"
|
||||
@@ -240,7 +237,6 @@
|
||||
"newPasswordRepeat": "Gentag ny adgangskode"
|
||||
},
|
||||
"enable2FA": {
|
||||
"description": "Din Cloudron-administrator har krævet, at alle medlemmer skal aktivere to-faktor-autentifikation. Du vil ikke kunne få adgang til instrumentbrættet, før du aktiverer 2FA.",
|
||||
"authenticatorAppDescription": "Brug Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP-autenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) eller en lignende TOTP-app til at scanne hemmeligheden.",
|
||||
"title": "Aktiver to-faktor-autentifikation",
|
||||
"token": "Token",
|
||||
@@ -300,8 +296,6 @@
|
||||
"password": "Adgangskode til bekræftelse"
|
||||
},
|
||||
"changePasswordAction": "Ændre adgangskode",
|
||||
"disable2FAAction": "Deaktivere 2FA",
|
||||
"enable2FAAction": "Aktiver 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "E-mail sendt til {{ email }}"
|
||||
}
|
||||
@@ -328,7 +322,6 @@
|
||||
"backupNow": "Backup nu"
|
||||
},
|
||||
"backupDetails": {
|
||||
"list": "Referencer til sikkerhedskopier af {{ appCount }} apps",
|
||||
"title": "Oplysninger om sikkerhedskopiering",
|
||||
"id": "Id",
|
||||
"date": "Dato",
|
||||
@@ -560,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.",
|
||||
@@ -586,7 +576,6 @@
|
||||
"setupAction": "Oprettelse af konto",
|
||||
"subscription": "Abonnement",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Annulleret og slutter den",
|
||||
"subscriptionChangeAction": "Ændre abonnement",
|
||||
"subscriptionReactivateAction": "Genaktivere abonnementet",
|
||||
"emailNotVerified": "E-mail endnu ikke bekræftet"
|
||||
@@ -631,7 +620,6 @@
|
||||
},
|
||||
"domainDialog": {
|
||||
"wildcardInfo": "Opsætning<i>A</i>records for <b>*.{{ domain }}.</b>og<b>{ domain }}.</b>til denne servers IP.",
|
||||
"wellKnownDescription": "Værdierne vil blive brugt af Cloudron til at svare på <code>/.well-known/</code> URL'er. Bemærk, at en app skal være tilgængelig på det nøgne domæne <code>{{{ domæne }}</code> for at dette kan fungere. Se <a href=\"{{docsLink}}}\" target=\"_blank\">docs</a> for flere oplysninger.",
|
||||
"addTitle": "Tilføj domæne",
|
||||
"editTitle": "Konfigurer {{ domain }}",
|
||||
"domain": "Domæne",
|
||||
@@ -697,11 +685,7 @@
|
||||
"title": "Synkronisering af DNS",
|
||||
"description": "Dette vil reprovisionere app- og e-mail-DNS-poster på tværs af alle domæner.",
|
||||
"syncAction": "Synkronisering af DNS"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Well-Known locations på {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Indstil well-known lokationer"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"markAllAsRead": "Markér alle som læst",
|
||||
@@ -1050,7 +1034,6 @@
|
||||
"description": "Sikkerhedskopier er komplette snapshots af appen. Du kan bruge app-backups til at gendanne eller klone denne app.",
|
||||
"downloadBackupTooltip": "Download Sikkerhedskopi",
|
||||
"title": "Sikkerhedskopiering",
|
||||
"time": "Oprettet på",
|
||||
"downloadConfigTooltip": "Download Backup-konfiguration",
|
||||
"cloneTooltip": "Klon fra denne sikkerhedskopi",
|
||||
"restoreTooltip": "Gendan til denne sikkerhedskopi",
|
||||
@@ -1079,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.",
|
||||
@@ -1155,8 +1133,7 @@
|
||||
"saveAction": "Gem"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "Deaktivere indeksering"
|
||||
"title": "Robots.txt"
|
||||
},
|
||||
"hstsPreload": "Aktiver HSTS-forudindlæsning for dette websted og alle underdomæner"
|
||||
},
|
||||
@@ -1239,7 +1216,6 @@
|
||||
"description": "Kontakt din serveradministrator for at få et nyt invitationslink.",
|
||||
"title": "Ugyldigt eller udløbet inviteringslink"
|
||||
},
|
||||
"welcomeTo": "Velkommen til",
|
||||
"description": "Opret venligst din konto",
|
||||
"username": "Brugernavn",
|
||||
"fullName": "Fuldt navn",
|
||||
@@ -1262,7 +1238,6 @@
|
||||
"welcomeTo": "Velkommen til <%= cloudronName %>!",
|
||||
"salutation": "Hej <%= user %>,",
|
||||
"inviteLinkAction": "Kom i gang",
|
||||
"expireNote": "Bemærk venligst, at linket til invitationen udløber om 7 dage.",
|
||||
"inviteLinkActionText": "Følg linket for at komme i gang: <%- inviteLink %>",
|
||||
"subject": "Velkommen til <%= cloudron %>"
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"main": {
|
||||
"offline": "Cloudron is offline. Reconnecting…",
|
||||
"logout": "Logout",
|
||||
"logout": "Log out",
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
@@ -35,8 +35,8 @@
|
||||
"displayName": "Display name",
|
||||
"actions": "Actions",
|
||||
"table": {
|
||||
"date": "Date",
|
||||
"version": "Version"
|
||||
"version": "Version",
|
||||
"created": "Created"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Reboot",
|
||||
@@ -45,7 +45,12 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"next": "Next",
|
||||
"configure": "Configure"
|
||||
"configure": "Configure",
|
||||
"restart": "Restart",
|
||||
"reset": "Reset",
|
||||
"loadMore": "Load more",
|
||||
"setup": "Set up",
|
||||
"disable": "Disable"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Reboot Server",
|
||||
@@ -62,7 +67,13 @@
|
||||
"groups": "Groups"
|
||||
},
|
||||
"statusEnabled": "Enabled",
|
||||
"loadingPlaceholder": "Loading"
|
||||
"loadingPlaceholder": "Loading",
|
||||
"platform": {
|
||||
"startupFailed": "Platform startup failed"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Collapse sidebar"
|
||||
}
|
||||
},
|
||||
"appstore": {
|
||||
"title": "App Store",
|
||||
@@ -96,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": {
|
||||
@@ -113,13 +127,13 @@
|
||||
"setGhostTooltip": "Impersonate",
|
||||
"mailmanagerTooltip": "This user can manage users and mailboxes",
|
||||
"noMatchesPlaceholder": "No matching user",
|
||||
"emptyPlaceholder": "No Users"
|
||||
"emptyPlaceholder": "No users"
|
||||
},
|
||||
"groups": {
|
||||
"name": "Name",
|
||||
"users": "Users",
|
||||
"externalLdapTooltip": "From external LDAP directory",
|
||||
"emptyPlaceholder": "No Groups",
|
||||
"emptyPlaceholder": "No groups",
|
||||
"noMatchesPlaceholder": "No matching group"
|
||||
},
|
||||
"settings": {
|
||||
@@ -131,7 +145,7 @@
|
||||
"externalLdap": {
|
||||
"title": "Connect an External Directory",
|
||||
"description": "Synchronize and authenticate users and groups from an external LDAP or Active Directory server. Synchronization runs periodically every 4 hours.",
|
||||
"noopInfo": "No external directory configured.",
|
||||
"noopInfo": "No external directory configured",
|
||||
"provider": "Provider",
|
||||
"server": "Server URL",
|
||||
"acceptSelfSignedCert": "Accept self-signed certificate",
|
||||
@@ -161,13 +175,13 @@
|
||||
"username": "Username",
|
||||
"role": "Role",
|
||||
"groups": "Groups",
|
||||
"noGroups": "No groups available.",
|
||||
"usernamePlaceholder": "Optional. If not provided, user can pick during sign up",
|
||||
"noGroups": "No groups available",
|
||||
"usernamePlaceholder": "Optional. If not provided, user can pick during sign up.",
|
||||
"displayName": "Display name",
|
||||
"primaryEmail": "Primary email",
|
||||
"recoveryEmail": "Password recovery email",
|
||||
"activeCheckbox": "User is active",
|
||||
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
|
||||
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up.",
|
||||
"fallbackEmailPlaceholder": "If not specified, primary email will be used"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
@@ -251,7 +265,11 @@
|
||||
"title": "LDAP Server",
|
||||
"enabled": "Enable LDAP server"
|
||||
},
|
||||
"title": "Users"
|
||||
"title": "Users",
|
||||
"2FAResetDialog": {
|
||||
"title": "Reset User 2FA",
|
||||
"description": "Remove the existing 2FA setup for user “{{ username }}”?"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profile",
|
||||
@@ -272,24 +290,29 @@
|
||||
},
|
||||
"enable2FA": {
|
||||
"title": "Enable Two-Factor Authentication",
|
||||
"description": "Your Cloudron Administrator has required all members to enable two-factor authentication. You will be unable to access the dashboard until you enable 2FA.",
|
||||
"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"
|
||||
"enable": "Enable",
|
||||
"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",
|
||||
"name": "Name",
|
||||
"description": "Use these personal access tokens to authenticate with the <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>.",
|
||||
"noTokensPlaceholder": "No API tokens",
|
||||
"lastUsed": "Last Used",
|
||||
"lastUsed": "Last used",
|
||||
"neverUsed": "never",
|
||||
"scope": "Scope",
|
||||
"readonly": "Readonly",
|
||||
@@ -300,7 +323,7 @@
|
||||
"loginTokens": {
|
||||
"title": "Login Tokens",
|
||||
"description": "You have {{ webadminTokenCount}} active web token(s) and {{ cliTokenCount }} CLI token(s).",
|
||||
"logoutAll": "Logout from all"
|
||||
"logoutAll": "Log out from all"
|
||||
},
|
||||
"changeEmail": {
|
||||
"title": "Change Primary Email",
|
||||
@@ -315,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",
|
||||
@@ -326,8 +350,6 @@
|
||||
"allowedIpRanges": "Allowed IP range(s)"
|
||||
},
|
||||
"changePasswordAction": "Change password",
|
||||
"disable2FAAction": "Disable 2FA",
|
||||
"enable2FAAction": "Enable 2FA",
|
||||
"passwordResetNotification": {
|
||||
"body": "Email sent to {{ email }}"
|
||||
},
|
||||
@@ -338,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": {
|
||||
@@ -355,21 +397,24 @@
|
||||
"noBackups": "No backups",
|
||||
"contents": "Contents",
|
||||
"version": "Version",
|
||||
"noApps": "No Apps",
|
||||
"noApps": "No apps",
|
||||
"appCount": "{{ appCount }} App(s)",
|
||||
"tooltipDownloadBackupConfig": "Download config",
|
||||
"cleanupBackups": "Cleanup backups",
|
||||
"backupNow": "Backup now",
|
||||
"tooltipPreservedBackup": "This backup will be preserved"
|
||||
"tooltipPreservedBackup": "This backup will be preserved",
|
||||
"description": "System backups contain Cloudron configuration and app installation metadata. They can be used to <a href=\"{{restoreLink}}\" target=\"_blank\">restore</a> or <a href=\"{{migrateLink}}\" target=\"_blank\">migrate</a> the entire Cloudron installation to another server."
|
||||
},
|
||||
"backupDetails": {
|
||||
"title": "Backup Details",
|
||||
"id": "Id",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
"list": "References backups of {{ appCount }} app(s)",
|
||||
"id": "Backup ID",
|
||||
"date": "Created",
|
||||
"version": "Package version",
|
||||
"size": "Size",
|
||||
"duration": "Duration"
|
||||
"duration": "Backup duration",
|
||||
"lastIntegrityCheck": "Last integrity check",
|
||||
"integrityNever": "never",
|
||||
"integrityInProgress": "In progress"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configure Backup Schedule & Retention",
|
||||
@@ -443,7 +488,9 @@
|
||||
"title": "Backups of automatic updates",
|
||||
"description": "A backup is always created before automatic updates. Select this option to store those backups on this site."
|
||||
},
|
||||
"useEncryption": "Encrypt backups"
|
||||
"useEncryption": "Encrypt backups",
|
||||
"regionHelperText": "Defaults to \"us-east-1\" if left empty",
|
||||
"prefixHelperText": "Backups are stored inside this subfolder"
|
||||
},
|
||||
"backupEdit": {
|
||||
"title": "Edit Backup",
|
||||
@@ -485,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",
|
||||
@@ -560,8 +609,8 @@
|
||||
"customRulesPlaceholder": "Custom Spamassassin Rules"
|
||||
},
|
||||
"testMailDialog": {
|
||||
"title": "Send test email for {{ domain }}",
|
||||
"description": "This will send a test email from <b>no-reply@{{ domain }}</b> to the address below.",
|
||||
"title": "Send test email",
|
||||
"description": "Sends a test email from <b>no-reply@{{ domain }}</b> to the specified address.",
|
||||
"sendAction": "Send"
|
||||
},
|
||||
"solrConfig": {
|
||||
@@ -652,7 +701,6 @@
|
||||
"setupAction": "Set up account",
|
||||
"subscription": "Subscription",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Canceled and ends on",
|
||||
"subscriptionChangeAction": "Manage subscription",
|
||||
"subscriptionReactivateAction": "Reactivate Subscription",
|
||||
"emailNotVerified": "Email not yet verified",
|
||||
@@ -673,17 +721,16 @@
|
||||
"updateAvailableAction": "Update available",
|
||||
"stopUpdateAction": "Stop update",
|
||||
"disabled": "Disabled",
|
||||
"schedule": "Update schedule",
|
||||
"description": "Platform and app updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest"
|
||||
"description": "Updates are applied on the configured schedule, using the <a href=\"/#/system-settings\">System time zone</a>.",
|
||||
"onLatest": "latest",
|
||||
"config": "Automatic updates",
|
||||
"appsOnly": "Apps only",
|
||||
"platformAndApps": "Platform & apps"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "Configure Automatic Update Schedule",
|
||||
"disableCheckbox": "Disable automatic updates",
|
||||
"enableCheckbox": "Enable automatic updates",
|
||||
"selectOne": "Select at least one day and time",
|
||||
"days": "Days",
|
||||
"hours": "Hours",
|
||||
"description": "Set the days and times for automatic platform and app updates. Ensure this schedule doesn’t overlap with backup schedules."
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -703,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": {
|
||||
@@ -760,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",
|
||||
@@ -788,7 +845,7 @@
|
||||
"wildcardInfo": "Manually set up A (IPv4) and AAAA (IPv6) DNS records for <b>*.{{ domain }}.</b> and <b>{{ domain }}.</b> pointing to this server",
|
||||
"letsEncryptInfo": "Let's Encrypt requires your server to be reachable on port 80",
|
||||
"advancedAction": "Advanced settings…",
|
||||
"zoneName": "Zone name (optional)",
|
||||
"zoneName": "Zone name",
|
||||
"fallbackCert": "Fallback Certificate (optional)",
|
||||
"fallbackCertCustomCert": "Custom Certificate",
|
||||
"fallbackCertCustomCertInfo": "Provide a <a href=\"{{ customCertLink }}\" target=\"_blank\">wildcard certificate</a> to use for all apps on this domain. If not provided, a self-signed certificate is automatically generated.",
|
||||
@@ -800,7 +857,6 @@
|
||||
"netcupApiKey": "Netcup API key",
|
||||
"netcupApiPassword": "Netcup API password",
|
||||
"vultrToken": "Vultr token",
|
||||
"wellKnownDescription": "The values will be used to respond to <code>https://{{ domain }}/.well-known/</code> URLs. Note that an app must be available on the bare domain <code>{{ domain }}</code> for this to work. See the <a href=\"{{docsLink}}\" target=\"_blank\">docs</a> for more information.",
|
||||
"jitsiHostname": "Jitsi location",
|
||||
"hetznerToken": "Hetzner token",
|
||||
"cloudflareDefaultProxyStatus": "Enable proxying for new DNS records",
|
||||
@@ -818,7 +874,10 @@
|
||||
"gandiTokenTypePAT": "Personal Access Token (PAT)",
|
||||
"inwxUsername": "INWX username",
|
||||
"inwxPassword": "INWX password",
|
||||
"customNameservers": "Domain uses custom (vanity) nameservers"
|
||||
"customNameservers": "Domain uses custom (vanity) nameservers",
|
||||
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain.",
|
||||
"carddavLocation": "CardDAV server location",
|
||||
"caldavLocation": "CalDAV server location"
|
||||
},
|
||||
"removeDialog": {
|
||||
"title": "Remove Domain",
|
||||
@@ -830,13 +889,15 @@
|
||||
"description": "Updates app and email DNS records for all domains.",
|
||||
"syncAction": "Sync DNS"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Well-Known Locations of {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Well-Known locations",
|
||||
"emptyPlaceholder": "No Domains",
|
||||
"noMatchesPlaceholder": "No matching domain",
|
||||
"description": "Adding a domain allows you to install apps on its subdomains."
|
||||
"description": "Adding a domain allows you to install apps on its subdomains.",
|
||||
"wellknown": {
|
||||
"editAction": "Well-known URIs",
|
||||
"title": "Well-known URIs",
|
||||
"context": "Configure responses to \"https://{{ domain }}/.well-known/\" URLs",
|
||||
"description": "This feature requires an app installed on the root domain \"{{ domain }}\". See the <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> for details."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"dismissTooltip": "Dismiss",
|
||||
@@ -850,12 +911,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",
|
||||
@@ -879,11 +947,11 @@
|
||||
"reallyDelete": "Really delete?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "New Folder Name",
|
||||
"title": "New folder",
|
||||
"create": "Create"
|
||||
},
|
||||
"newFileDialog": {
|
||||
"title": "New Filename",
|
||||
"title": "New filename",
|
||||
"create": "Create"
|
||||
},
|
||||
"renameDialog": {
|
||||
@@ -901,16 +969,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 }})",
|
||||
@@ -997,7 +1066,7 @@
|
||||
"owner": "Owner",
|
||||
"aliases": "Aliases",
|
||||
"usage": "Usage",
|
||||
"emptyPlaceholder": "No Mailboxes",
|
||||
"emptyPlaceholder": "No mailboxes",
|
||||
"noMatchesPlaceholder": "No matching mailboxes",
|
||||
"stats": "Count: {{ mailboxCount }} / Usage: {{ usage }}"
|
||||
},
|
||||
@@ -1084,7 +1153,7 @@
|
||||
"title": "Edit Mailbox",
|
||||
"owner": "Mailbox owner",
|
||||
"aliases": "Aliases",
|
||||
"noAliases": "No aliases.",
|
||||
"noAliases": "No aliases",
|
||||
"addAliasAction": "Add an alias",
|
||||
"addAnotherAliasAction": "Add another alias",
|
||||
"enableStorageQuota": "Storage quota"
|
||||
@@ -1161,11 +1230,11 @@
|
||||
"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": {
|
||||
"description": "Configure who can log in and use the app.",
|
||||
"description": "Configure who can log in and use the app",
|
||||
"descriptionSftp": "This setting also controls SFTP access.",
|
||||
"dashboardVisibility": "Dashboard visibility",
|
||||
"visibleForAllUsers": "Visible to all users on this Cloudron",
|
||||
@@ -1179,7 +1248,7 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"description": "Operators can configure and maintain this app."
|
||||
"description": "Configure who can maintain the app"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Configure who can see this app on the dashboard."
|
||||
@@ -1258,14 +1327,29 @@
|
||||
},
|
||||
"security": {
|
||||
"csp": {
|
||||
"description": "Override any CSP headers defined by the app.",
|
||||
"description": "Override any CSP headers defined by the app",
|
||||
"title": "Content Security Policy",
|
||||
"saveAction": "Save"
|
||||
"saveAction": "Save",
|
||||
"insertCommonCsp": "Insert common CSP",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Allow embedding",
|
||||
"sameOriginEmbedding": "Allow embedding (only subdomains)",
|
||||
"allowCdnAssets": "Allow CDN assets",
|
||||
"reportOnly": "Report CSP violations",
|
||||
"strictBaseline": "Strict baseline"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "Disable indexing",
|
||||
"description": "By default, bots can index this app."
|
||||
"description": "By default, bots can index this app",
|
||||
"commonPattern": {
|
||||
"allowAll": "Allow all (default)",
|
||||
"disallowAll": "Disallow all",
|
||||
"disallowCommonBots": "Disallow common bots",
|
||||
"disallowAdminPaths": "Disallow admin paths",
|
||||
"disallowApiPaths": "Disallow API paths"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Insert common robots.txt"
|
||||
},
|
||||
"hstsPreload": "Enable HSTS Preload (including subdomains)"
|
||||
},
|
||||
@@ -1276,21 +1360,21 @@
|
||||
"packageVersion": "Package version",
|
||||
"lastUpdated": "Last updated",
|
||||
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
|
||||
"installedAt": "Installed at"
|
||||
"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": {
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"description": "Create a complete snapshot of the app.",
|
||||
"time": "Created at",
|
||||
"description": "Create a complete snapshot of the app",
|
||||
"downloadConfigTooltip": "Download config",
|
||||
"cloneTooltip": "Clone",
|
||||
"restoreTooltip": "Restore",
|
||||
@@ -1301,7 +1385,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Import",
|
||||
"description": "Import the app from an external backup."
|
||||
"description": "Import app from an external backup"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatic backups",
|
||||
@@ -1327,11 +1411,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.",
|
||||
@@ -1387,7 +1466,7 @@
|
||||
"cron": {
|
||||
"title": "Crontab",
|
||||
"saveAction": "Save",
|
||||
"addCommonPattern": "Add common pattern",
|
||||
"addCommonPattern": "Insert common pattern",
|
||||
"commonPattern": {
|
||||
"everyMinute": "Every Minute",
|
||||
"everyHour": "Every Hour",
|
||||
@@ -1443,6 +1522,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": {
|
||||
@@ -1453,7 +1542,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",
|
||||
@@ -1473,11 +1565,10 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Password changed",
|
||||
"openDashboardAction": "Open Dashboard"
|
||||
"openDashboardAction": "Open dashboard"
|
||||
}
|
||||
},
|
||||
"setupAccount": {
|
||||
"welcomeTo": "Welcome to",
|
||||
"description": "Please set up your account",
|
||||
"username": "Username",
|
||||
"fullName": "Full name",
|
||||
@@ -1492,19 +1583,19 @@
|
||||
},
|
||||
"success": {
|
||||
"title": "Your account is ready",
|
||||
"openDashboardAction": "Open Dashboard"
|
||||
"openDashboardAction": "Open dashboard"
|
||||
},
|
||||
"noUsername": {
|
||||
"title": "Cannot set up account",
|
||||
"description": "Account cannot be set up without a username."
|
||||
}
|
||||
"description": "Account cannot be set up without a username. Please contact the administrator."
|
||||
},
|
||||
"welcome": "Welcome"
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"welcomeTo": "Welcome to <%= cloudronName %>!",
|
||||
"salutation": "Hi <%= user %>,",
|
||||
"inviteLinkAction": "Get started",
|
||||
"invitor": "You are receiving this email because you were invited by <%= invitor %>.",
|
||||
"expireNote": "Please note that the invite link will expire in 7 days.",
|
||||
"inviteLinkActionText": "Follow the link to get started: <%- inviteLink %>",
|
||||
"subject": "Welcome to <%= cloudron %>"
|
||||
},
|
||||
@@ -1587,7 +1678,7 @@
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Client credentials",
|
||||
"description": "Copy the credentials for client \"{{ clientName }}\"."
|
||||
"description": "Copy the credentials for client \"{{ clientName }}\""
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
@@ -1598,7 +1689,8 @@
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "No archived apps"
|
||||
}
|
||||
},
|
||||
"description": "Archived apps preserve the latest backup when an app was archived. These backups are kept permanently and can be restored."
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
@@ -1609,7 +1701,9 @@
|
||||
"sites": {
|
||||
"title": "Backup Sites",
|
||||
"emptyPlaceholder": "No backup sites",
|
||||
"lastRun": "Last run"
|
||||
"lastRun": "Last run",
|
||||
"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": {
|
||||
@@ -1646,11 +1740,11 @@
|
||||
"dashboard": {
|
||||
"title": "Dashboard"
|
||||
},
|
||||
"externallinks": {
|
||||
"label": "External links",
|
||||
"description": "Add shortcuts to external services on the dashboard"
|
||||
},
|
||||
"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
@@ -31,9 +31,6 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"actions": "Actions",
|
||||
"displayName": "Nom affiché",
|
||||
"table": {
|
||||
"date": "Date"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Journaux",
|
||||
"reboot": "Redémarrer"
|
||||
@@ -237,7 +234,6 @@
|
||||
"title": "Modifier l'adresse email de récupération du mot de passe"
|
||||
},
|
||||
"enable2FA": {
|
||||
"description": "Votre administrateur Cloudron a demandé à tous les membres d'activer l'authentification à deux facteurs (2FA). Pour accéder au tableau de bord, veuillez l'activer.",
|
||||
"token": "Jeton",
|
||||
"title": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"enable": "Activer",
|
||||
@@ -267,8 +263,6 @@
|
||||
"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 }}"
|
||||
}
|
||||
@@ -336,8 +330,7 @@
|
||||
"title": "Informations sur la sauvegarde",
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
"list": "Contient les sauvegardes de {{ appCount }} application(s)"
|
||||
"version": "Version"
|
||||
},
|
||||
"listing": {
|
||||
"title": "Liste",
|
||||
@@ -496,7 +489,6 @@
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Réactiver l'abonnement",
|
||||
"subscriptionChangeAction": "Modifier l'abonnement",
|
||||
"subscriptionEndsAt": "Prend fin le",
|
||||
"cloudronId": "ID Cloudron",
|
||||
"subscription": "Abonnement",
|
||||
"setupAction": "Créer un compte",
|
||||
@@ -523,12 +515,9 @@
|
||||
},
|
||||
"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",
|
||||
@@ -623,7 +612,6 @@
|
||||
"title": "Politique de sécurité du contenu (CSP)"
|
||||
},
|
||||
"robots": {
|
||||
"disableIndexingAction": "Désactiver l'indexation",
|
||||
"title": "Robots.txt"
|
||||
},
|
||||
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
|
||||
@@ -721,11 +709,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": {
|
||||
@@ -743,7 +726,6 @@
|
||||
"restoreTooltip": "Restaurer depuis cette sauvegarde",
|
||||
"cloneTooltip": "Cloner depuis cette sauvegarde",
|
||||
"downloadConfigTooltip": "Télécharger le fichier de configuration de la sauvegarde",
|
||||
"time": "Créée le",
|
||||
"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"
|
||||
@@ -1048,7 +1030,6 @@
|
||||
"editTitle": "Paramétrer {{ domain }}",
|
||||
"addTitle": "Ajouter un domaine",
|
||||
"vultrToken": "Token Vultr",
|
||||
"wellKnownDescription": "Les valeurs seront utilisées par Cloudron pour répondre aux URL <code>/.well-known/</code>. Notez qu'une application doit être disponible sur le domaine nu <code>{{ domaine }}</code> pour que cela fonctionne. Consultez la <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> pour plus d'informations.",
|
||||
"hetznerToken": "Token Hetzner",
|
||||
"jitsiHostname": "Emplacement de Jitsi",
|
||||
"cloudflareDefaultProxyStatus": "Activer le proxy pour les nouveaux enregistrements DNS",
|
||||
@@ -1077,11 +1058,7 @@
|
||||
},
|
||||
"provider": "Fournisseur",
|
||||
"domain": "Domaine",
|
||||
"title": "Domaines et Certificats",
|
||||
"domainWellKnown": {
|
||||
"title": "Emplacements Well-Known de {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Définir des emplacements Well-Known"
|
||||
"title": "Domaines et Certificats"
|
||||
},
|
||||
"branding": {
|
||||
"footer": {
|
||||
@@ -1101,7 +1078,6 @@
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"inviteLinkActionText": "Cliquez sur le lien pour démarrer : <%- inviteLink %>",
|
||||
"expireNote": "Veuillez noter que le lien d'invitation expire dans 7 jours.",
|
||||
"invitor": "Vous recevez ce message car vous avez été invité par <%= invitor %>.",
|
||||
"inviteLinkAction": "Démarrez",
|
||||
"subject": "Bienvenue sur <%= cloudron %>",
|
||||
@@ -1295,7 +1271,6 @@
|
||||
"fullName": "Nom complet",
|
||||
"username": "Nom d'utilisateur",
|
||||
"description": "Veuillez paramétrer votre compte",
|
||||
"welcomeTo": "Bienvenue sur",
|
||||
"noUsername": {
|
||||
"title": "Impossible de configurer le compte",
|
||||
"description": "Le compte ne peut pas être configuré sans nom d'utilisateur."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,6 @@
|
||||
"logs": "Logs",
|
||||
"reboot": "Riavvia il server"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Azioni",
|
||||
"displayName": "Nome visualizzato",
|
||||
"username": "Nome utente",
|
||||
@@ -61,7 +58,6 @@
|
||||
"welcomeEmail": {
|
||||
"subject": "Benvenuti in <%= cloudron %>",
|
||||
"inviteLinkActionText": "Segui questo link per iniziare: <%- inviteLink %>",
|
||||
"expireNote": "Tieni presente che il link di invito scadrà tra 7 giorni.",
|
||||
"invitor": "Hai ricevuto questa email perché sei stato invitato da <%= invitor %>.",
|
||||
"inviteLinkAction": "Iniziare",
|
||||
"salutation": "Ciao <%= user %>,",
|
||||
@@ -83,8 +79,7 @@
|
||||
"password": "Nuova Password",
|
||||
"fullName": "Nome e Cognome",
|
||||
"username": "Nome Utente",
|
||||
"description": "Per favore configura il tuo account",
|
||||
"welcomeTo": "Benvenuti"
|
||||
"description": "Per favore configura il tuo account"
|
||||
},
|
||||
"passwordReset": {
|
||||
"success": {
|
||||
@@ -136,8 +131,7 @@
|
||||
},
|
||||
"security": {
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "Disabilita indicizzazione"
|
||||
"title": "Robots.txt"
|
||||
},
|
||||
"csp": {
|
||||
"saveAction": "Salva",
|
||||
@@ -175,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": {
|
||||
@@ -209,7 +198,6 @@
|
||||
"restoreTooltip": "Ripristina su questo backup",
|
||||
"cloneTooltip": "Clona da questo backup",
|
||||
"downloadConfigTooltip": "Scarica la configurazione di backup",
|
||||
"time": "Creato alle",
|
||||
"description": "I backup sono istantanee complete dell'app. Puoi utilizzare i backup delle app per ripristinare o clonare questa app.",
|
||||
"title": "Backup"
|
||||
}
|
||||
@@ -548,7 +536,6 @@
|
||||
"title": "Configura pianificazione e conservazione backup"
|
||||
},
|
||||
"backupDetails": {
|
||||
"list": "Riferimenti ai bakcup di {{ appCount }} applicazioni",
|
||||
"version": "Versione",
|
||||
"date": "Data",
|
||||
"title": "Dettagli Backup",
|
||||
@@ -573,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.",
|
||||
@@ -616,7 +601,6 @@
|
||||
"enable2FA": {
|
||||
"enable": "Abilita",
|
||||
"authenticatorAppDescription": "Usa 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>) o una qualsiasi app TOTP per eseguire la scansione del codice segreto.",
|
||||
"description": "Il tuo amministratore Cloudron ha richiesto a tutti i membri di abilitare l'autenticazione a due fattori. Non sarai in grado di accedere alla dashboard finché non abiliti 2FA.",
|
||||
"title": "Abilita autenticazione a Due Fattori",
|
||||
"token": "Token"
|
||||
},
|
||||
@@ -796,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",
|
||||
@@ -816,7 +797,6 @@
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Riattiva Abbonamento",
|
||||
"subscriptionChangeAction": "Cambia Abbonamento",
|
||||
"subscriptionEndsAt": "Annullato e termina il",
|
||||
"cloudronId": "ID Cloudron",
|
||||
"subscription": "Abbonamento",
|
||||
"setupAction": "Imposta Account",
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"logs": "ログ",
|
||||
"reboot": "再起動"
|
||||
},
|
||||
"table": {
|
||||
"date": "日付"
|
||||
},
|
||||
"displayName": "表示名",
|
||||
"username": "ユーザー名",
|
||||
"dialog": {
|
||||
|
||||
@@ -34,8 +34,8 @@
|
||||
"displayName": "Weergavenaam",
|
||||
"actions": "Acties",
|
||||
"table": {
|
||||
"date": "Datum",
|
||||
"version": "Versie"
|
||||
"version": "Versie",
|
||||
"created": "Aangemaakt"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Herstart",
|
||||
@@ -44,7 +44,12 @@
|
||||
"edit": "Bewerk",
|
||||
"add": "Toevoegen",
|
||||
"next": "Volgende",
|
||||
"configure": "Configureer"
|
||||
"configure": "Configureer",
|
||||
"restart": "Herstart",
|
||||
"reset": "Reset",
|
||||
"loadMore": "Laad meer",
|
||||
"setup": "Instellen",
|
||||
"disable": "Uitschakelen"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Herstart Server",
|
||||
@@ -62,7 +67,13 @@
|
||||
"groups": "Groepen"
|
||||
},
|
||||
"statusEnabled": "Ingeschakeld",
|
||||
"loadingPlaceholder": "Laden"
|
||||
"loadingPlaceholder": "Laden",
|
||||
"platform": {
|
||||
"startupFailed": "Platformstart mislukt"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapseAction": "Zijbalk inklappen"
|
||||
}
|
||||
},
|
||||
"appstore": {
|
||||
"title": "App Store",
|
||||
@@ -96,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": {
|
||||
@@ -130,7 +144,7 @@
|
||||
},
|
||||
"externalLdap": {
|
||||
"title": "Verbind met een externe lijst",
|
||||
"noopInfo": "Geen externe directory geconfigureerd.",
|
||||
"noopInfo": "Geen externe directory geconfigureerd",
|
||||
"provider": "Aanbieder",
|
||||
"acceptSelfSignedCert": "Accepteer zelf-ondertekend certificaat",
|
||||
"baseDn": "Base DN",
|
||||
@@ -161,14 +175,14 @@
|
||||
"username": "Gebruikersnaam",
|
||||
"role": "Rol",
|
||||
"groups": "Groepen",
|
||||
"noGroups": "Geen groepen beschikbaar.",
|
||||
"noGroups": "Geen groepen beschikbaar",
|
||||
"displayName": "Weergavenaam",
|
||||
"primaryEmail": "Primair e-mailadres",
|
||||
"recoveryEmail": "Wachtwoordherstel e-mailadres",
|
||||
"activeCheckbox": "Gebruiker is actief",
|
||||
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
|
||||
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen.",
|
||||
"fallbackEmailPlaceholder": "Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
|
||||
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
|
||||
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding."
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"deleteAction": "Verwijder",
|
||||
@@ -251,7 +265,11 @@
|
||||
"title": "LDAP Server",
|
||||
"enabled": "LDAP server inschakelen"
|
||||
},
|
||||
"title": "Gebruikers"
|
||||
"title": "Gebruikers",
|
||||
"2FAResetDialog": {
|
||||
"title": "Reset Gebruiker 2FA",
|
||||
"description": "Verwijder de bestaande 2FA voor gebruiker “{{ username }}”?"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profiel",
|
||||
@@ -274,15 +292,20 @@
|
||||
"token": "Token",
|
||||
"enable": "Inschakelen",
|
||||
"title": "Schakel Twee-Factor (2FA) authenticatie in",
|
||||
"description": "Jouw Cloudron Administrator heeft Twee-Factor (2FA) authenticatie voor alle gebruikers verplicht gesteld. Schakel jouw 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."
|
||||
"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.",
|
||||
"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",
|
||||
@@ -315,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",
|
||||
@@ -326,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 }}"
|
||||
},
|
||||
@@ -338,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": {
|
||||
@@ -355,21 +397,24 @@
|
||||
"noBackups": "Geen backups",
|
||||
"contents": "Inhoud",
|
||||
"version": "Versie",
|
||||
"noApps": "Geen Apps",
|
||||
"noApps": "Geen apps",
|
||||
"cleanupBackups": "Backups opschonen",
|
||||
"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",
|
||||
"id": "Id",
|
||||
"date": "Datum",
|
||||
"version": "Versie",
|
||||
"list": "Verwijst naar backups van {{ appCount }} app(s)",
|
||||
"id": "Backup ID",
|
||||
"date": "Aangemaakt",
|
||||
"version": "Package versie",
|
||||
"size": "Grootte",
|
||||
"duration": "Duur"
|
||||
"duration": "Backup duur",
|
||||
"lastIntegrityCheck": "Laatste integriteitscontrole",
|
||||
"integrityNever": "nooit",
|
||||
"integrityInProgress": "In uitvoering"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "Configureer Backup Planning & Bewaartermijn",
|
||||
@@ -443,7 +488,9 @@
|
||||
"title": "Backups van automatische updates",
|
||||
"description": "Vóór automatische updates wordt altijd een back-up gemaakt. Kies deze optie indien je die back-ups op deze locatie wilt opslaan."
|
||||
},
|
||||
"useEncryption": "Encrypt backups"
|
||||
"useEncryption": "Encrypt backups",
|
||||
"regionHelperText": "Standaardwaarde is \"us-east-1\" als deze leeg is",
|
||||
"prefixHelperText": "Backups worden opgeslagen in deze sub-map"
|
||||
},
|
||||
"backupEdit": {
|
||||
"preserved": {
|
||||
@@ -485,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",
|
||||
@@ -560,8 +609,8 @@
|
||||
"blacklisteAddressesInfo": "Overeenkomende adressen belanden in de Spam folder van de gebruikers. '*' en '?' glob patronen worden ondersteund."
|
||||
},
|
||||
"testMailDialog": {
|
||||
"title": "Verstuur test e-mail voor {{ domain }}",
|
||||
"description": "Hiermee stuur je een test e-mail van <b>no-reply@{{ domain }}</b> aan onderstaand adres.",
|
||||
"title": "Verstuur test e-mail",
|
||||
"description": "Stuur een test e-mail van <b>no-reply@{{ domain }}</b> naar het opgegeven adres.",
|
||||
"sendAction": "Verstuur"
|
||||
},
|
||||
"solrConfig": {
|
||||
@@ -606,7 +655,7 @@
|
||||
"manualInfo": "Alle DNS-records moeten handmatig worden aangemaakt voordat een app geïnstalleerd kan worden.",
|
||||
"wildcardInfo": "Stel handmatig A (IPv4) and AAAA (IPv6) DNS records in voor <b>*.{{ domain }}</b> en <b>{{ domain }}</b> met verwijzingen naar deze Cloudron server",
|
||||
"advancedAction": "Geavanceerde instellingen …",
|
||||
"zoneName": "Zone naam (optioneel)",
|
||||
"zoneName": "Zone naam",
|
||||
"fallbackCert": "Reservecertificaat (optioneel)",
|
||||
"fallbackCertCustomCert": "Aangepast certificaat",
|
||||
"fallbackCertCustomCertInfo": "Voorzie een <a href=\"{{ customCertLink }}\" target=\"_blank\">wildcard certificaat</a> voor gebruik door alle apps op dit domein. Als dit niet wordt verstrekt, wordt automatisch een zelfondertekend certificaat aangemaakt.",
|
||||
@@ -622,7 +671,6 @@
|
||||
"netcupApiPassword": "Netcup API wachtwoord",
|
||||
"vultrToken": "Vultr token",
|
||||
"jitsiHostname": "Jitsi locatie",
|
||||
"wellKnownDescription": "De waardes worden gebruikt om te reageren op <code>https://{{ domain }}/.well-known/</code> URLs. Let op: de app moet bereikbaar zijn op het hoofddomein <code>{{ domain }}</code> om te kunnen werken. Lees de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor meer informatie.",
|
||||
"hetznerToken": "Hetzner token",
|
||||
"cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels",
|
||||
"porkbunApikey": "Porkbun API sleutel",
|
||||
@@ -639,7 +687,10 @@
|
||||
"gandiTokenTypePAT": "Persoonlijke Toegang Token (PAT)",
|
||||
"inwxUsername": "INWX gebruikersnaam",
|
||||
"inwxPassword": "INWX wachtwoord",
|
||||
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers"
|
||||
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers",
|
||||
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt.",
|
||||
"carddavLocation": "CardDAV-server locatie",
|
||||
"caldavLocation": "CalDAV server locatie"
|
||||
},
|
||||
"title": "Domeinen",
|
||||
"domain": "Domein",
|
||||
@@ -652,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",
|
||||
@@ -664,13 +717,15 @@
|
||||
"description": "Update app en e-mail DNS records van alle domeinen.",
|
||||
"title": "Sync DNS"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Well-Known locaties van {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Well-Known locaties",
|
||||
"emptyPlaceholder": "Geen Domeinen",
|
||||
"noMatchesPlaceholder": "Geen bijbehorende domein",
|
||||
"description": "Het toevoegen van een domein maakt het mogelijk om apps te installeren op de subdomeinen ervan."
|
||||
"description": "Het toevoegen van een domein maakt het mogelijk om apps te installeren op de subdomeinen ervan.",
|
||||
"wellknown": {
|
||||
"editAction": "Well-known URIs",
|
||||
"title": "Well-known URIs",
|
||||
"context": "Configureer reacties op de URL's \"https://{{ domain }}/.well-known/\"",
|
||||
"description": "Deze functie vereist een app die is geïnstalleerd op het hoofddomein \"{{ domain }}\". Zie de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor details."
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"email": {
|
||||
@@ -730,12 +785,11 @@
|
||||
"noRedirections": "Geen domein-omleidingen",
|
||||
"noAliases": "Geen alias-domeinen",
|
||||
"addAliasAction": "Alias toevoegen",
|
||||
"aliases": "Aliassen",
|
||||
"dnsoverwrite": "Sommige DNS records bestaan al. Weet je zeker dat ze overschreven moeten worden?"
|
||||
"aliases": "Aliassen"
|
||||
},
|
||||
"accessControl": {
|
||||
"userManagement": {
|
||||
"description": "Instellen wie kan inloggen en de app gebruiken.",
|
||||
"description": "Instellen wie kan inloggen en de app gebruiken",
|
||||
"dashboardVisibility": "Dashboardzichtbaarheid",
|
||||
"visibleForSelected": "Alleen zichtbaar voor de volgende gebruikers en groepen",
|
||||
"descriptionSftp": "Deze instelling regelt ook SFTP-toegang.",
|
||||
@@ -749,7 +803,7 @@
|
||||
},
|
||||
"operators": {
|
||||
"title": "Operators",
|
||||
"description": "Operators kunnen deze app configureren en onderhouden."
|
||||
"description": "Instellen wie deze app kan onderhouden"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Instellen wie deze app op het dashboard kan zien."
|
||||
@@ -806,14 +860,29 @@
|
||||
},
|
||||
"security": {
|
||||
"csp": {
|
||||
"description": "Overschrijf alle CSP-headers die door de app zijn gedefinieerd.",
|
||||
"description": "Overschrijf alle CSP-headers die door de app zijn gedefinieerd",
|
||||
"title": "Content Security Policy",
|
||||
"saveAction": "Opslaan"
|
||||
"saveAction": "Opslaan",
|
||||
"insertCommonCsp": "Voeg veelvoorkomende CSP-regels toe",
|
||||
"commonPattern": {
|
||||
"allowEmbedding": "Embedden toestaan",
|
||||
"sameOriginEmbedding": "Embedden toestaan (alleen subdomeinen)",
|
||||
"allowCdnAssets": "CDN-assets toestaan",
|
||||
"reportOnly": "Rapporteer CSP-overtredingen",
|
||||
"strictBaseline": "Strikte baselijn"
|
||||
}
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "Indexering uitschakelen",
|
||||
"description": "Standaard kunnen bots deze app indexeren."
|
||||
"description": "Standaard kunnen bots deze app indexeren",
|
||||
"commonPattern": {
|
||||
"allowAll": "Allen toestaan (standaard)",
|
||||
"disallowAll": "Iedereen niet toestaan",
|
||||
"disallowCommonBots": "Veelvoorkomende bots blokkeren",
|
||||
"disallowAdminPaths": "Admin-paden niet toestaan",
|
||||
"disallowApiPaths": "API-paden niet toestaan"
|
||||
},
|
||||
"insertCommonRobotsTxt": "Voeg standaard robots.txt toe"
|
||||
},
|
||||
"hstsPreload": "Schakel HSTS-preload in (inclusief subdomeinen)"
|
||||
},
|
||||
@@ -824,24 +893,24 @@
|
||||
"packageVersion": "Pakketversie",
|
||||
"lastUpdated": "Laatst geüpdatet",
|
||||
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
|
||||
"installedAt": "Geïnstalleerd op"
|
||||
"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": {
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"time": "Aangemaakt op",
|
||||
"downloadConfigTooltip": "Download Configuratie",
|
||||
"createBackupAction": "Maak backup",
|
||||
"importAction": "Importeer backup",
|
||||
"description": "Maak een volledige snapshot van de app.",
|
||||
"description": "Maak een volledige snapshot van de app",
|
||||
"cloneTooltip": "Kloon",
|
||||
"restoreTooltip": "Herstel",
|
||||
"downloadBackupTooltip": "Download",
|
||||
@@ -849,7 +918,7 @@
|
||||
},
|
||||
"import": {
|
||||
"title": "Importeer",
|
||||
"description": "Importeer de app vanuit een externe back-up."
|
||||
"description": "Importeer app vanuit een externe back-up"
|
||||
},
|
||||
"auto": {
|
||||
"title": "Automatische backups",
|
||||
@@ -875,11 +944,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",
|
||||
@@ -947,7 +1011,7 @@
|
||||
},
|
||||
"title": "Crontab",
|
||||
"saveAction": "Opslaan",
|
||||
"addCommonPattern": "Voeg gemeenschappelijk patroon toe",
|
||||
"addCommonPattern": "Veelvoorkomend patroon invoegen",
|
||||
"description": "Cron-taken die nodig zijn voor de werking van de app zijn al opgenomen in het app-pakket. Voeg hier uitsluitend extra taken toe die specifiek zijn voor jouw installatie."
|
||||
},
|
||||
"sftpInfoAction": "SFTP Toegang",
|
||||
@@ -993,6 +1057,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": {
|
||||
@@ -1062,7 +1136,6 @@
|
||||
"setupAction": "Instellen account",
|
||||
"subscription": "Abonnement",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Opgezegd en eindigt op",
|
||||
"subscriptionChangeAction": "Beheer abonnement",
|
||||
"subscriptionReactivateAction": "Abonnement heractiveren",
|
||||
"title": "Cloudron.io Account",
|
||||
@@ -1084,18 +1157,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": {
|
||||
@@ -1116,6 +1188,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": {
|
||||
@@ -1173,12 +1253,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",
|
||||
@@ -1202,11 +1289,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": {
|
||||
@@ -1224,15 +1311,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": {
|
||||
@@ -1319,7 +1407,7 @@
|
||||
"aliases": "Aliassen",
|
||||
"usage": "Gebruik",
|
||||
"title": "E-mailboxen",
|
||||
"emptyPlaceholder": "Geen Mailboxen",
|
||||
"emptyPlaceholder": "Geen mailboxen",
|
||||
"noMatchesPlaceholder": "Geen bijbehorende mailboxen",
|
||||
"stats": "Aantal: {{ mailboxCount }} / Opslaggebruik: {{ usage }}"
|
||||
},
|
||||
@@ -1407,7 +1495,7 @@
|
||||
"title": "Bewerk mailbox",
|
||||
"owner": "Mailbox-eigenaar",
|
||||
"aliases": "Aliassen",
|
||||
"noAliases": "Geen aliassen.",
|
||||
"noAliases": "Geen aliassen",
|
||||
"addAliasAction": "Alias toevoegen",
|
||||
"addAnotherAliasAction": "Een andere alias toevoegen",
|
||||
"enableStorageQuota": "Opslagquota"
|
||||
@@ -1453,14 +1541,17 @@
|
||||
"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",
|
||||
"usernameOrEmail": "Gebruikersnaam of e-mail",
|
||||
"resetAction": "Reset",
|
||||
"success": {
|
||||
"openDashboardAction": "Open Dashboard",
|
||||
"openDashboardAction": "Open dashboard",
|
||||
"title": "Wachtwoord veranderd"
|
||||
},
|
||||
"passwordChanged": {
|
||||
@@ -1516,7 +1607,6 @@
|
||||
"welcomeEmail": {
|
||||
"subject": "Welkom bij <%= cloudron %>",
|
||||
"inviteLinkActionText": "Volg deze link om te starten: <%- inviteLink %>",
|
||||
"expireNote": "Deze uitnodigingslink is 7 dagen geldig.",
|
||||
"invitor": "Je ontvangt deze e-mail omdat je bent uitgenodigd door <%= invitor %>.",
|
||||
"inviteLinkAction": "Start hier",
|
||||
"salutation": "Hallo <%= user %>,",
|
||||
@@ -1524,7 +1614,7 @@
|
||||
},
|
||||
"setupAccount": {
|
||||
"success": {
|
||||
"openDashboardAction": "Open Dashboard",
|
||||
"openDashboardAction": "Open dashboard",
|
||||
"title": "Je account is klaar"
|
||||
},
|
||||
"invalidToken": {
|
||||
@@ -1539,11 +1629,11 @@
|
||||
"fullName": "Volledige naam",
|
||||
"username": "Gebruikersnaam",
|
||||
"description": "Stel je account in",
|
||||
"welcomeTo": "Welkom bij",
|
||||
"noUsername": {
|
||||
"title": "Account kan niet ingesteld worden",
|
||||
"description": "Account kan niet ingesteld worden zonder gebruikersnaam."
|
||||
}
|
||||
"description": "Account kan niet ingesteld worden zonder gebruikersnaam. Neem contact op met de administrator."
|
||||
},
|
||||
"welcome": "Welkom"
|
||||
},
|
||||
"newLoginEmail": {
|
||||
"subject": "[<%= cloudron %>] Er is vanaf een nieuwe locatie ingelogd op je account",
|
||||
@@ -1587,7 +1677,7 @@
|
||||
},
|
||||
"clientCredentials": {
|
||||
"title": "Clientreferenties",
|
||||
"description": "Kopieer de inloggegevens voor client \"{{ clientName }}\"."
|
||||
"description": "Kopieer de inloggegevens voor client \"{{ clientName }}\""
|
||||
}
|
||||
},
|
||||
"userdirectory": {
|
||||
@@ -1598,7 +1688,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": {
|
||||
@@ -1609,7 +1700,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": {
|
||||
@@ -1646,11 +1739,11 @@
|
||||
"dashboard": {
|
||||
"title": "Dashboard"
|
||||
},
|
||||
"externallinks": {
|
||||
"label": "Externe links",
|
||||
"description": "Voegt snelkoppelingen naar externe diensten toe aan het dashboard"
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,6 @@
|
||||
"logs": "Logi",
|
||||
"reboot": "Restart"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Akcje",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"username": "Użytkownik",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"noMatchesPlaceholder": "Sem aplicações correspondentes"
|
||||
},
|
||||
"main": {
|
||||
"displayName": "Nome a Exibir",
|
||||
"displayName": "Nome a exibir",
|
||||
"rebootDialog": {
|
||||
"description": "Todas as aplicações e serviços irão iniciar automaticamente. <br/><br/>Reiniciar agora o servidor?",
|
||||
"title": "Reiniciar Servidor",
|
||||
@@ -36,11 +36,10 @@
|
||||
"done": "Concluído",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"logout": "Terminar Sessão",
|
||||
"logout": "Terminar sessão",
|
||||
"username": "Nome de Utilizador",
|
||||
"actions": "Ações",
|
||||
"table": {
|
||||
"date": "Data",
|
||||
"version": "Versão"
|
||||
},
|
||||
"action": {
|
||||
@@ -50,7 +49,9 @@
|
||||
"edit": "Editar",
|
||||
"add": "Adicionar",
|
||||
"next": "Seguinte",
|
||||
"configure": "Configurar"
|
||||
"configure": "Configurar",
|
||||
"restart": "Reiniciar",
|
||||
"reset": "Reiniciar"
|
||||
},
|
||||
"searchPlaceholder": "Pesquisar",
|
||||
"multiselect": {
|
||||
@@ -62,7 +63,13 @@
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"statusEnabled": "Ativado",
|
||||
"loadingPlaceholder": "A carregar"
|
||||
"loadingPlaceholder": "A carregar",
|
||||
"sidebar": {
|
||||
"collapseAction": "Ocultar barra lateral"
|
||||
},
|
||||
"platform": {
|
||||
"startupFailed": "O arranque da plataforma falhou"
|
||||
}
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
@@ -73,14 +80,14 @@
|
||||
"installDialog": {
|
||||
"lastUpdated": "Última atualização em {{ date }}",
|
||||
"locationPlaceholder": "Deixe em branco para utilizar o domínio de raiz",
|
||||
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
|
||||
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores.",
|
||||
"memoryRequirement": "Requer pelo menos {{ size }} de memória",
|
||||
"location": "Localização",
|
||||
"manualWarning": "Configure manualmente os registos A (IPv4) e AAA (IPv6) para <b>{{ location }}</b> apontando para este servidor",
|
||||
"userManagement": "Gestão de Utilizadores",
|
||||
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
|
||||
"userManagement": "Gestão de utilizadores",
|
||||
"userManagementMailbox": "Os utilizadores com uma <a href=\"/#/mailboxes\">caixa de correio</a> podem autenticar-se com o seu ''e-mail' e palavra-passe do Cloudron.",
|
||||
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
|
||||
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
|
||||
"userManagementAllUsers": "Permitir todos os utilizadores neste Cloudron",
|
||||
"userManagementSelectUsers": "Permitir apenas os seguintes utilizadores e grupos",
|
||||
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
|
||||
"users": "Utilizadores",
|
||||
@@ -100,23 +107,23 @@
|
||||
},
|
||||
"profile": {
|
||||
"changeEmail": {
|
||||
"password": "Palavra-passe para confirmação",
|
||||
"password": "Confirmar com Palavra-passe",
|
||||
"email": "Novo Endereço de Correio Eletrónico",
|
||||
"title": "Alterar endereço de correio eletrónico principal"
|
||||
"title": "Alterar Endereço de Correio Eletrónico Principal"
|
||||
},
|
||||
"changePassword": {
|
||||
"title": "Alterar Palavra-passe",
|
||||
"currentPassword": "Palavra-passe Atual",
|
||||
"newPassword": "Nova Palavra-passe",
|
||||
"newPasswordRepeat": "Repetir Nova Palavra-passe",
|
||||
"currentPassword": "Palavra-passe atual",
|
||||
"newPassword": "Nova palavra-passe",
|
||||
"newPasswordRepeat": "Repetir nova palavra-passe",
|
||||
"errorPasswordsDontMatch": "As palavras-passe não coincidem"
|
||||
},
|
||||
"enable2FA": {
|
||||
"title": "Ativar Autenticação de Dois Fatores",
|
||||
"token": "Código",
|
||||
"enable": "Ativar",
|
||||
"description": "O seu administrador do Cloudron exigiu que todos os membros ativassem a autenticação de dois fatores. Você não poderá aceder ao painel até ativar a 2FA.",
|
||||
"authenticatorAppDescription": "Utilize o Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), autenticador FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou uma aplicação de TOTP similar para digitalizar o segredo."
|
||||
"authenticatorAppDescription": "Utilize o Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), autenticador FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou uma aplicação de TOTP similar para digitalizar o segredo.",
|
||||
"mandatorySetup": "É necessário a 2FA para aceder ao painel de controlo. Por favor, complete a configuração para continuar."
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "Códigos de API",
|
||||
@@ -127,13 +134,13 @@
|
||||
"readwrite": "Ler e Gravar",
|
||||
"name": "Nome",
|
||||
"description": "Utilize estes códigos de acesso pessoais para autenticar a <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API do Cloudron</a>",
|
||||
"noTokensPlaceholder": "Sem Códigos da API criados",
|
||||
"noTokensPlaceholder": "Sem códigos da API",
|
||||
"allowedIpRanges": "IPs Permitidos",
|
||||
"allowedIpRangesPlaceholder": "IPs ou sub-redes separados por vírgulas"
|
||||
},
|
||||
"createAppPassword": {
|
||||
"name": "Nome da Palavra-passe",
|
||||
"title": "Criar Palavra-passe da Aplicação",
|
||||
"name": "Nome da palavra-passe",
|
||||
"title": "Adicionar Palavra-passe da Aplicação",
|
||||
"app": "Aplicação",
|
||||
"description": "Utilize a palavra-passe seguinte para se autenticar na aplicação:",
|
||||
"copyNow": "Por favor, copie a palavra-passe agora. Esta não será mostrada novamente por motivos de segurança."
|
||||
@@ -144,13 +151,13 @@
|
||||
"description": "Novo código de API:",
|
||||
"access": "Acesso de API",
|
||||
"copyNow": "Por favor, copie o código da API agora. Este não será mostrado novamente por motivos de segurança.",
|
||||
"allowedIpRanges": "Intervalo(s) de IP Permitido(s)"
|
||||
"allowedIpRanges": "Intervalo(s) de IP permitido(s)"
|
||||
},
|
||||
"passwordResetNotification": {
|
||||
"body": "Mensagem enviada para {{ email }}"
|
||||
},
|
||||
"title": "Perfil",
|
||||
"primaryEmail": "E-mail Principal",
|
||||
"primaryEmail": "E-mail principal",
|
||||
"language": "Idioma",
|
||||
"disable2FA": {
|
||||
"title": "Desativar Autenticação de Dois Fatores",
|
||||
@@ -158,11 +165,11 @@
|
||||
"disable": "Desativar"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"title": "Alterar endereço de correio eletrónico da recuperação de palavra-passe"
|
||||
"title": "Alterar Endereço de Correio Eletrónico da Recuperação da Palavra-passe"
|
||||
},
|
||||
"loginTokens": {
|
||||
"title": "Códigos de Autenticação",
|
||||
"logoutAll": "Terminar Sessão de Todos",
|
||||
"logoutAll": "Terminar sessão de todos",
|
||||
"description": "Tem {{ webadminTokenCount}} código(s) da Web ativo(s) e {{ cliTokenCount }} código(s) de CLI."
|
||||
},
|
||||
"appPasswords": {
|
||||
@@ -172,28 +179,28 @@
|
||||
"noPasswordsPlaceholder": "Sem palavras-passe da aplicação",
|
||||
"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",
|
||||
"changePasswordAction": "Alterar palavra-passe",
|
||||
"removeAppPassword": {
|
||||
"title": "Deseja remover a palavra-passe {{ name }}?"
|
||||
"title": "Remover Palavra-passe da Aplicação",
|
||||
"description": "Remover a palavra-passe da aplicação \"{{ name }}\"?"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Deseja remover o código {{ name }}?"
|
||||
"title": "Deseja remover o código {{ name }}?",
|
||||
"description": "Remover o código da API \"{{ name }}\"?"
|
||||
},
|
||||
"passwordRecoveryEmail": "Mensagem de Recuperação da Palavra-passe"
|
||||
"passwordRecoveryEmail": "Mensagem de recuperação da palavra-passe"
|
||||
},
|
||||
"users": {
|
||||
"exposedLdap": {
|
||||
"ipRestriction": {
|
||||
"label": "Restringir Acesso",
|
||||
"placeholder": "Endereço de IP ou sub-redes separados por linha",
|
||||
"description": "Limite o acesso do Servidor de Diretoria para IPs ou intervalos específicos. As linhas que começam com <code>#</code> são tratadas como comentários."
|
||||
"label": "IPs e limites permitidos",
|
||||
"placeholder": "Endereço de IP ou sub-redes separados por linha. As linhas que comecem com <code>#</code> são tratadas como comentários.",
|
||||
"description": "Limite o acesso do Servidor de Diretoria para IPs ou intervalos específicos"
|
||||
},
|
||||
"secret": {
|
||||
"label": "Associar Palavra-passe",
|
||||
"label": "Associar palavra-passe",
|
||||
"url": "URL do Servidor",
|
||||
"description": "Todas as consultas de LDAP tem de ser autenticadas com este segredo e o utilizador <i>{{ userDN }}</i> de DN"
|
||||
"description": "Autenticar consultas com o DN de utilizador <i>{{ userDN }}</i> e este segredo"
|
||||
},
|
||||
"description": "O servidor LDAP permite que as aplicações externas autentiquem os utilizadores na diretoria de utilizadores do Cloudron.",
|
||||
"cloudflarePortWarning": "O proxy de Cloudflare deve estar desativado no domínio do painel para aceder ao servidor LDAP",
|
||||
@@ -215,10 +222,10 @@
|
||||
"externalLdapTooltip": "Da diretoria LDAP externa",
|
||||
"resetPasswordTooltip": "Redefinir Palavra-passe",
|
||||
"noMatchesPlaceholder": "Nenhum utilizador correspondente",
|
||||
"emptyPlaceholder": "Sem Utilizadores"
|
||||
"emptyPlaceholder": "Sem utilizadores"
|
||||
},
|
||||
"groups": {
|
||||
"emptyPlaceholder": "Sem Grupos",
|
||||
"emptyPlaceholder": "Sem grupos",
|
||||
"name": "Nome",
|
||||
"users": "Utilizadores",
|
||||
"externalLdapTooltip": "Da diretoria LDAP externa",
|
||||
@@ -229,12 +236,12 @@
|
||||
"username": "Nome de utilizador",
|
||||
"role": "Função",
|
||||
"groups": "Grupos",
|
||||
"noGroups": "Nenhum grupo disponível.",
|
||||
"displayName": "Nome a Exibir",
|
||||
"noGroups": "Nenhum grupo disponível",
|
||||
"displayName": "Nome a exibir",
|
||||
"primaryEmail": "E-mail principal",
|
||||
"usernamePlaceholder": "Opcional. Se não for fornecido, o utilizador pode escolher durante o registo",
|
||||
"usernamePlaceholder": "Opcional. Se não fornecido, o utilizador pode escolher durante o registo.",
|
||||
"activeCheckbox": "O utilizador está ativo",
|
||||
"displayNamePlaceholder": "Opcional. Se não fornecido, o utilizador pode fornecer durante o registo",
|
||||
"displayNamePlaceholder": "Opcional. Se não fornecido, o utilizador pode fornecer durante o registo.",
|
||||
"fallbackEmailPlaceholder": "Se não especificado, será utilizado o e-mail principal",
|
||||
"recoveryEmail": "Mensagem de recuperação da palavra-passe"
|
||||
},
|
||||
@@ -248,7 +255,7 @@
|
||||
},
|
||||
"editUserDialog": {
|
||||
"externalLdapWarning": "Este utilizador é sincronizado a partir da diretoria LDAP externa.",
|
||||
"title": "Editar utilizador {{ username }}"
|
||||
"title": "Editar Utilizador"
|
||||
},
|
||||
"deleteGroupDialog": {
|
||||
"description": "Este grupo tem {{ memberCount }} membro(s).<br/><br/>Eliminar grupo\"{{ name }}\"?",
|
||||
@@ -256,29 +263,30 @@
|
||||
"title": "Eliminar Grupo"
|
||||
},
|
||||
"invitationDialog": {
|
||||
"descriptionEmail": "Enviar Hiperligação de Convite",
|
||||
"title": "Convidar {{ username }}",
|
||||
"descriptionEmail": "Enviar hiperligação de convite",
|
||||
"title": "Convidar Utilizador",
|
||||
"sendAction": "Enviar mensagem",
|
||||
"descriptionLink": "Hiperligação de Convite",
|
||||
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:"
|
||||
"descriptionLink": "Hiperligação de convite",
|
||||
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:",
|
||||
"context": "Convidar utilizador \"{{ username }}\""
|
||||
},
|
||||
"externalLdap": {
|
||||
"autocreateUsersOnLogin": "Criar Utilizadores Automaticamente ao Iniciar a Sessão",
|
||||
"autocreateUsersOnLogin": "Criar utilizadores automaticamente ao iniciar a sessão",
|
||||
"provider": "Fornecedor",
|
||||
"server": "URL do Servidor",
|
||||
"filter": "Filtro",
|
||||
"usernameField": "Campo do Nome do Utilizador",
|
||||
"syncGroups": "Sincronizar Grupos",
|
||||
"usernameField": "Campo do nome do utilizador",
|
||||
"syncGroups": "Sincronizar grupos",
|
||||
"auth": "Autenticar",
|
||||
"syncAction": "Sincronizar",
|
||||
"syncAction": "Sincronizar agora",
|
||||
"configureAction": "Configurar",
|
||||
"noopInfo": "A autenticação LDAP não está configurada.",
|
||||
"noopInfo": "Nenhuma diretoria externa configurada",
|
||||
"title": "Ligar uma Diretoria Externa",
|
||||
"acceptSelfSignedCert": "Aceitar Certificado Auto Assinado",
|
||||
"acceptSelfSignedCert": "Aceitar certificado auto assinado",
|
||||
"groupnameField": "Campo do Nome do Grupo",
|
||||
"errorSelfSignedCert": "O servidor está a utilizar um certificado inválido ou assinado automaticamente.",
|
||||
"description": "Esta definição sincronizará e autenticará os utilizadores e grupos de um servidor LDAP ou Active Directory externa. A sincronização é executada periodicamente, mas também pode ser acionada manualmente.",
|
||||
"bindPassword": "Vincular Palavra-passe (opcional)",
|
||||
"description": "Sincronize e autentique os utilizadores e os grupos de um servidor LDAP ou Active Directory externa. A sincronização é executada periodicamente a cada 4 horas.",
|
||||
"bindPassword": "Associar palavra-passe (opcional)",
|
||||
"disableWarning": "A fonte de autenticação de todos os utilizadores existentes será reiniciada para se autenticar na base de dados da palavra-passe atual.",
|
||||
"baseDn": "Base DN",
|
||||
"bindUsername": "Vincular DN/Nome de utilizador (opcional)",
|
||||
@@ -286,9 +294,9 @@
|
||||
"groupBaseDn": "Base DN do Grupo"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"title": "Eliminar utilizador {{ username }}",
|
||||
"title": "Eliminar Utilizador",
|
||||
"deleteAction": "Eliminar",
|
||||
"description": "Depois da eliminação, o utilizador não poderá aceder ao painel ou iniciar a sessão em quaisquer aplicações. Note que não são removidos quaisquer dados do utilizador dentro das aplicações."
|
||||
"description": "Depois da eliminação, o utilizador não poderá aceder ao painel ou iniciar a sessão em quaisquer aplicações. Note que não são removidos quaisquer dados do utilizador dentro das aplicações. <br/><br/>Eliminar utilizador \"{{ username }}\"?"
|
||||
},
|
||||
"externalLdapDialog": {
|
||||
"title": "Configurar LDAP"
|
||||
@@ -301,7 +309,7 @@
|
||||
"mailmanager": "Gestor de E-mails e Utilizadores"
|
||||
},
|
||||
"setGhostDialog": {
|
||||
"password": "Palavra-passe Temporária",
|
||||
"password": "Palavra-passe temporária",
|
||||
"setPassword": "Definir palavra-passe",
|
||||
"generatePassword": "Gerar Palavra-passe",
|
||||
"title": "Fazer-se passar pelo Utilizador",
|
||||
@@ -319,21 +327,26 @@
|
||||
"group": {
|
||||
"name": "Nome",
|
||||
"users": "Utilizadores",
|
||||
"addGroupAction": "Adicionar Grupo"
|
||||
"addGroupAction": "Adicionar",
|
||||
"allowedApps": "Aplicações permitidas"
|
||||
},
|
||||
"editGroupDialog": {
|
||||
"title": "Editar grupo {{ name }}",
|
||||
"title": "Editar Grupo",
|
||||
"externalLdapWarning": "Este grupo é sincronizado a partir da diretoria LDAP externa."
|
||||
},
|
||||
"addUserDialog": {
|
||||
"title": "Adicionar Utilizador",
|
||||
"addUserAction": "Adicionar Utilizador",
|
||||
"addUserAction": "Adicionar",
|
||||
"sendInviteCheckbox": "Enviar mensagem de convite"
|
||||
},
|
||||
"invitationNotification": {
|
||||
"body": "Mensagem enviada para {{ email }}"
|
||||
},
|
||||
"title": "Utilizadores"
|
||||
"title": "Utilizadores",
|
||||
"2FAResetDialog": {
|
||||
"title": "Reiniciar 2FA do Utilizador",
|
||||
"description": "Remover a configuração existente de 2FA para o utilizador \"{{ username }}\"?"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"2faToken": "Código 2FA",
|
||||
@@ -512,7 +525,7 @@
|
||||
"version": "Versão",
|
||||
"noApps": "Sem Aplicações",
|
||||
"appCount": "Aplicações: {{ appCount }}",
|
||||
"backupNow": "Copiar Agora",
|
||||
"backupNow": "Copiar agora",
|
||||
"tooltipPreservedBackup": "Esta cópia de segurança será preservada",
|
||||
"title": "Cópias de Segurança do Sistema",
|
||||
"noBackups": "Sem Cópias de Segurança",
|
||||
@@ -524,7 +537,8 @@
|
||||
"id": "Id.",
|
||||
"date": "Data",
|
||||
"version": "Versão",
|
||||
"list": "Referencia as cópias de segurança de {{ appCount }} aplicação(ões)"
|
||||
"size": "Tamanho",
|
||||
"duration": "Duração"
|
||||
}
|
||||
},
|
||||
"passwordReset": {
|
||||
@@ -603,7 +617,6 @@
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Procurar por Atualizações",
|
||||
"schedule": "Agendar",
|
||||
"updateAvailableAction": "Disponível Atualização",
|
||||
"stopUpdateAction": "Parar Atualização",
|
||||
"disabled": "Desativada"
|
||||
@@ -617,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"
|
||||
@@ -764,35 +775,100 @@
|
||||
"checkIntegrity": "Verificar Integridade"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar da Cópia de Segurança Externa"
|
||||
"title": "Importar da Cópia de Segurança Externa",
|
||||
"description": "Importar a aplicação de uma cópia de segurança externa."
|
||||
},
|
||||
"auto": {
|
||||
"title": "Cópias de segurança automáticas"
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"taskError": {
|
||||
"description": "Se uma instalação, configuração, atualização, restauração ou cópia de segurança resultou num erro, pode tentar novamente a tarefa.",
|
||||
"retryAction": "Repetir Tarefa {{ task }}"
|
||||
"description": "Repetir uma instalação falhada, configuração, atualização, restauro, ou tarefa de cópia de segurança.",
|
||||
"retryAction": "Repetir tarefa {{ task }}",
|
||||
"title": "Erro de tarefa"
|
||||
},
|
||||
"recovery": {
|
||||
"title": "Modo de Recuperação"
|
||||
"title": "Modo de Recuperação",
|
||||
"restartAction": "Reiniciar",
|
||||
"disableAction": "Desativar modo de recuperação",
|
||||
"enableAction": "Ativar modo de recuperação"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Reiniciar",
|
||||
"description": "Se a aplicação não responder, tente reinstalar a mesma."
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"info": {
|
||||
"customAppUpdateInfo": "A atualização automática não está disponível para as aplicações personalizadas.",
|
||||
"installedAt": "Instalado às",
|
||||
"lastUpdated": "Última Atualização",
|
||||
"packageVersion": "Versão do Pacote",
|
||||
"description": "Título e Versão da Aplicação"
|
||||
"installedAt": "Instalado",
|
||||
"lastUpdated": "Última atualização",
|
||||
"packageVersion": "Versão do pacote",
|
||||
"description": "Título e Versão da Aplicação",
|
||||
"appId": "Id. da Aplicação"
|
||||
},
|
||||
"auto": {
|
||||
"description": "As atualizações da aplicação são aplicadas periodicamente, com base no <a href=\"/#/system-update\">agendamento da atualização</a>",
|
||||
"title": "Atualizações automáticas"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron procura automaticamente por atualizações na 'Loja de Aplicações'. Você também podes procurar manualmente."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"hstsPreload": "Ativar pré-carregamento de HSTS para este site e todos os subdomínios"
|
||||
"hstsPreload": "Ativar Pré-carregamento de HSTS (incluindo os subdomínios)",
|
||||
"csp": {
|
||||
"title": "Política de Segurança de Conteúdo",
|
||||
"saveAction": "Guardar"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "Por predefinição, os robôs podem indexar esta aplicação."
|
||||
}
|
||||
},
|
||||
"forumAction": "Fórum",
|
||||
"resources": {
|
||||
"devices": {
|
||||
"label": "Dispositivos"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"inbox": {
|
||||
"title": "Mensagens a receber",
|
||||
"enable": "Utilize Cloudron Mail para receber mensagens",
|
||||
"disable": "Não configurar caixa de entrada"
|
||||
},
|
||||
"from": {
|
||||
"title": "Correio dos endereços",
|
||||
"mailboxPlaceholder": "Nome da caixa de correio",
|
||||
"saveAction": "Guardar",
|
||||
"enable": "Utilize Cloudron Mail para enviar mensagens",
|
||||
"displayName": "De nome"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Correio a enviar"
|
||||
}
|
||||
},
|
||||
"graphs": {
|
||||
"period": {
|
||||
"1h": "1 hora",
|
||||
"12h": "12 horas",
|
||||
"24h": "24 horas",
|
||||
"7d": "7 dias",
|
||||
"30d": "30 dias",
|
||||
"6h": "6 horas"
|
||||
},
|
||||
"diskIOTotal": "Total de leitura: {{ read }} Total de gravação: {{ write }}",
|
||||
"networkIOTotal": "Total de a receber: {{ inbound }} Total de a enviar: {{ outbound }}"
|
||||
},
|
||||
"storage": {
|
||||
"mounts": {
|
||||
"permissions": {
|
||||
"readWrite": "Ler e Gravar",
|
||||
"label": "Permissões"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -831,10 +907,19 @@
|
||||
"name": "Nome",
|
||||
"id": "Id. do Cliente",
|
||||
"secret": "Segredo do Cliente",
|
||||
"signingAlgorithm": "Algoritmo de Assinatura"
|
||||
"signingAlgorithm": "Algoritmo de Assinatura",
|
||||
"loginRedirectUriPlaceholder": "URLs separados por vírgulas"
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "URL de Descobrir"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"description": "Copiar as credenciais para o cliente \"{{ clientName }}\"",
|
||||
"title": "Credenciais de cliente"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clientes de OpenID",
|
||||
"empty": "Sem clientes de OpenID"
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
@@ -874,7 +959,6 @@
|
||||
"errorPassword": "A palavra-passe deve ter pelo menos 8 carateres",
|
||||
"errorPasswordNoMatch": "As palavra-passe não coincidem",
|
||||
"setupAction": "Configurar",
|
||||
"welcomeTo": "Bem-vindo ao",
|
||||
"description": "Por favor, configure a sua conta",
|
||||
"username": "Nome de utilizador",
|
||||
"success": {
|
||||
@@ -883,7 +967,8 @@
|
||||
},
|
||||
"noUsername": {
|
||||
"title": "Não é possível configurar a conta"
|
||||
}
|
||||
},
|
||||
"welcome": "Bem-vindo"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"salutation": "Olá <%= user %>,",
|
||||
@@ -891,7 +976,14 @@
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Site da Cópia de Segurança"
|
||||
"label": "Site",
|
||||
"size": "Tamanho",
|
||||
"fileCount": "Ficheiros"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites de Cópias de Segurança",
|
||||
"emptyPlaceholder": "Sem ''sites'' de cópia de segurança",
|
||||
"lastRun": "Última execução"
|
||||
}
|
||||
},
|
||||
"filemanager": {
|
||||
@@ -900,5 +992,21 @@
|
||||
"download": "Transferir"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerRegistries": {
|
||||
"server": "Endereço do servidor",
|
||||
"provider": "Provedor",
|
||||
"username": "Nome de utilizador",
|
||||
"email": "E-mail",
|
||||
"passwordToken": "Palavra-passe/Código"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel"
|
||||
},
|
||||
"server": {
|
||||
"title": "Servidor"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,6 @@
|
||||
"yes": "ඔව්"
|
||||
},
|
||||
"username": "පරිශීලක නාමය",
|
||||
"table": {
|
||||
"date": "දිනය"
|
||||
},
|
||||
"searchPlaceholder": "සොයන්න",
|
||||
"multiselect": {
|
||||
"select": "තෝරන්න"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,8 +37,6 @@
|
||||
"copyNow": "请复制 API Token。出于安全考虑,这个 API Token 未来不会再显示。"
|
||||
},
|
||||
"changePasswordAction": "修改密码",
|
||||
"disable2FAAction": "停用双因素验证",
|
||||
"enable2FAAction": "启用双因素验证",
|
||||
"title": "个人资料",
|
||||
"primaryEmail": "主要 Email",
|
||||
"passwordRecoveryEmail": "密码恢复 Email",
|
||||
@@ -59,7 +57,6 @@
|
||||
"title": "启用双因素验证",
|
||||
"token": "动态验证码",
|
||||
"enable": "启用",
|
||||
"description": "您的 Cloudron 管理员要求所有用户启用双因素验证,在启用之前您无法使用控制面板。",
|
||||
"authenticatorAppDescription": "使用 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>) 或类似的动态验证码 App 来扫描。"
|
||||
},
|
||||
"appPasswords": {
|
||||
@@ -112,8 +109,7 @@
|
||||
"title": "备份详情",
|
||||
"id": "Id",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
"list": "备份了下列 {{ appCount }} 个应用"
|
||||
"version": "版本"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "配置备份计划和保留时间",
|
||||
@@ -185,9 +181,6 @@
|
||||
"username": "用户名",
|
||||
"displayName": "昵称",
|
||||
"actions": "操作",
|
||||
"table": {
|
||||
"date": "日期"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "重启",
|
||||
"logs": "日志"
|
||||
@@ -525,7 +518,6 @@
|
||||
"setupAction": "设置账户",
|
||||
"subscription": "订阅",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "已取消并将终止于",
|
||||
"subscriptionChangeAction": "更改订阅",
|
||||
"subscriptionReactivateAction": "重新激活订阅"
|
||||
},
|
||||
@@ -540,12 +532,9 @@
|
||||
"stopUpdateAction": "停止更新"
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
"title": "配置自动更新时间表",
|
||||
"disableCheckbox": "停用自动更新",
|
||||
"enableCheckbox": "启用自动更新",
|
||||
"selectOne": "选择至少一个日期和时间",
|
||||
"days": "星期",
|
||||
"hours": "小时",
|
||||
"description": "选择检查平台和应用更新的日子和时间。请注意这个时间不要和 <a href=\"/#/backups\">备份时间</a> 冲突。"
|
||||
},
|
||||
"updateDialog": {
|
||||
@@ -978,8 +967,7 @@
|
||||
"description": "使用此设置来覆盖应用自带的 CSP header"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "禁止爬取"
|
||||
"title": "Robots.txt"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
@@ -1028,7 +1016,6 @@
|
||||
"importAction": "导入备份",
|
||||
"title": "备份",
|
||||
"description": "备份是应用的完整快照。你可以使用应用的备份来恢复或者克隆该应用。",
|
||||
"time": "创建于",
|
||||
"downloadConfigTooltip": "下载备份的配置文件",
|
||||
"cloneTooltip": "由此备份克隆"
|
||||
},
|
||||
@@ -1042,11 +1029,6 @@
|
||||
}
|
||||
},
|
||||
"uninstall": {
|
||||
"startStop": {
|
||||
"startAction": "启动应用",
|
||||
"description": "可以通过停止应用(而非卸载)来节省服务器资源。停用后的自动备份不会包括当前的状态,有鉴于此,建议你在停止应用之前进行一次手动备份。",
|
||||
"stopAction": "停止应用"
|
||||
},
|
||||
"uninstall": {
|
||||
"description": "将会卸载此应用,并删除所有数据。卸载后该应用将不可用。",
|
||||
"title": "卸载",
|
||||
@@ -1099,7 +1081,6 @@
|
||||
"welcomeEmail": {
|
||||
"salutation": "<%= user %> 你好,",
|
||||
"inviteLinkAction": "开始",
|
||||
"expireNote": "请注意,邀请链接会在 7 天内失效。",
|
||||
"invitor": "您收到了 <%= invitor %> 的邀请注册邮件。",
|
||||
"inviteLinkActionText": "使用这个链接来开始注册:<%- inviteLink %>",
|
||||
"subject": "欢迎来到 <%= cloudron %>",
|
||||
@@ -1136,7 +1117,6 @@
|
||||
"title": "账户已就绪",
|
||||
"openDashboardAction": "打开控制面板"
|
||||
},
|
||||
"welcomeTo": "欢迎来到",
|
||||
"description": "请设置你的账户",
|
||||
"username": "用户名",
|
||||
"password": "新密码",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Restore</title>
|
||||
<title>Restore Cloudron</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Domain Setup</title>
|
||||
<title>Domain Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Account Setup</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
+241
-203
@@ -1,17 +1,25 @@
|
||||
<script setup>
|
||||
|
||||
import { onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher, SideBar } from '@cloudron/pankow';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, InputDialog, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
import { redirectIfNeeded, startAuthFlow } from './utils.js';
|
||||
import ProfileModel from './models/ProfileModel.js';
|
||||
import ProvisionModel from './models/ProvisionModel.js';
|
||||
import NotificationsModel from './models/NotificationsModel.js';
|
||||
import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import AppstoreModel from './models/AppstoreModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
import OfflineOverlay from './components/OfflineOverlay.vue';
|
||||
import SideBar from './components/SideBar.vue';
|
||||
import AppsView from './views/AppsView.vue';
|
||||
import AppConfigureView from './views/AppConfigureView.vue';
|
||||
import AppearanceView from './views/AppearanceView.vue';
|
||||
@@ -28,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';
|
||||
@@ -58,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',
|
||||
@@ -72,6 +82,174 @@ const VIEWS = Object.freeze({
|
||||
VOLUMES: '#/volumes',
|
||||
});
|
||||
|
||||
const menuItems = ref([{
|
||||
label: t('apps.title'),
|
||||
icon: 'fa fa-grip fa-fw',
|
||||
route: VIEWS.APPS,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
}, {
|
||||
label: t('appstore.title'),
|
||||
icon: 'fa fa-cloud-download-alt fa-fw',
|
||||
route: VIEWS.APPSTORE,
|
||||
active: () => view.value === VIEWS.APPSTORE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
label: t('domains.title'),
|
||||
icon: 'fa fa-globe fa-fw',
|
||||
route: VIEWS.DOMAINS,
|
||||
active: () => view.value === VIEWS.DOMAINS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('users.title'),
|
||||
icon: 'fa fa-users-gear fa-fw',
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
childItems: [{
|
||||
label: t('main.navbar.users'),
|
||||
icon: 'fa fa-user fa-fw',
|
||||
route: VIEWS.USERS,
|
||||
active: () => view.value === VIEWS.USERS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: t('main.navbar.groups'),
|
||||
icon: 'fa fa-users fa-fw',
|
||||
route: VIEWS.GROUPS,
|
||||
active: () => view.value === VIEWS.GROUPS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: 'LDAP',
|
||||
icon: 'fa fa-fw fa-users-rays',
|
||||
route: VIEWS.LDAP,
|
||||
active: () => view.value === VIEWS.LDAP,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: 'OpenID',
|
||||
icon: 'fa fa-fw fa-brands fa-openid',
|
||||
route: VIEWS.OPENID,
|
||||
active: () => view.value === VIEWS.OPENID,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('userdirectory.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
active: () => view.value === VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}],
|
||||
}, {
|
||||
label: t('emails.title'),
|
||||
icon: 'fa fa-envelope fa-fw',
|
||||
visible: () => profile.value.isAtLeastMailManager,
|
||||
childItems: [{
|
||||
label: 'Domains',
|
||||
icon: 'fa fa-fw fa-globe',
|
||||
route: VIEWS.EMAIL_DOMAINS,
|
||||
active: () => view.value === VIEWS.EMAIL_DOMAINS || view.value === VIEWS.EMAIL_DOMAIN,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('email.incoming.mailboxes.title'),
|
||||
icon: 'fa fa-fw fa-inbox',
|
||||
route: VIEWS.MAILBOXES,
|
||||
active: () => view.value === VIEWS.MAILBOXES,
|
||||
}, {
|
||||
label: t('email.incoming.mailinglists.title'),
|
||||
icon: 'fa fa-fw-solid fa-envelopes-bulk',
|
||||
route: VIEWS.MAILINGLISTS,
|
||||
active: () => view.value === VIEWS.MAILINGLISTS,
|
||||
}, {
|
||||
label: t('emails.eventlog.title'),
|
||||
icon: 'fa fa-fw fa-list-alt',
|
||||
route: VIEWS.EMAIL_EVENTLOG,
|
||||
active: () => view.value === VIEWS.EMAIL_EVENTLOG,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('emails.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.EMAIL_SETTINGS,
|
||||
active: () => view.value === VIEWS.EMAIL_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}]
|
||||
}, {
|
||||
label: t('network.title'),
|
||||
icon: 'fas fa-network-wired fa-fw',
|
||||
route: VIEWS.NETWORK,
|
||||
active: () => view.value === VIEWS.NETWORK,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('volumes.title'),
|
||||
icon: 'fa fa-hdd fa-fw',
|
||||
route: VIEWS.VOLUMES,
|
||||
active: () => view.value === VIEWS.VOLUMES,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('backups.title'),
|
||||
icon: 'fa fa-archive fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: t('backups.sites.title'),
|
||||
icon: 'fa fa-fw fa-hard-drive',
|
||||
route: VIEWS.BACKUP_SITES,
|
||||
active: () => view.value === VIEWS.BACKUP_SITES,
|
||||
}, {
|
||||
label: t('backups.archives.title'),
|
||||
icon: 'fa fa-fw fa-grip',
|
||||
route: VIEWS.APP_ARCHIVE,
|
||||
active: () => view.value === VIEWS.APP_ARCHIVE,
|
||||
}]
|
||||
}, {
|
||||
label: t('appearance.title'),
|
||||
icon: 'fa fa-pen-ruler fa-fw',
|
||||
route: VIEWS.APPEARANCE,
|
||||
active: () => view.value === VIEWS.APPEARANCE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('system.title'),
|
||||
icon: 'fa fa-server fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: 'Docker',
|
||||
icon: 'fa-brands fa-fw fa-docker',
|
||||
route: VIEWS.DOCKER,
|
||||
active: () => view.value === VIEWS.DOCKER,
|
||||
}, {
|
||||
label: t('services.title'),
|
||||
icon: 'fa fa-diagram-project fa-fw',
|
||||
route: VIEWS.SERVICES,
|
||||
active: () => view.value === VIEWS.SERVICES,
|
||||
}, {
|
||||
label: t('eventlog.title'),
|
||||
icon: 'fa fa-list-alt fa-fw',
|
||||
route: VIEWS.SYSTEM_EVENTLOG,
|
||||
active: () => view.value === VIEWS.SYSTEM_EVENTLOG,
|
||||
}, {
|
||||
label: t('settings.updates.title'),
|
||||
icon: 'fa fa-fw fa-square-up-right',
|
||||
route: VIEWS.SYSTEM_UPDATE,
|
||||
active: () => view.value === VIEWS.SYSTEM_UPDATE,
|
||||
}, {
|
||||
label: t('system.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.SYSTEM_SETTINGS,
|
||||
active: () => view.value === VIEWS.SYSTEM_SETTINGS,
|
||||
}]
|
||||
}, {
|
||||
separator: true,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('server.title'),
|
||||
icon: 'fa fa-fw fa-microchip',
|
||||
route: VIEWS.SERVER,
|
||||
active: () => view.value === VIEWS.SERVER,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('settings.appstoreAccount.title'),
|
||||
icon: 'fa fa-fw fa-crown',
|
||||
route: VIEWS.CLOUDRON_ACCOUNT,
|
||||
active: () => view.value === VIEWS.CLOUDRON_ACCOUNT,
|
||||
visible: () => profile.value.isAtLeastOwner,
|
||||
}]);
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
@@ -99,13 +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 sidebar = useTemplateRef('sidebar');
|
||||
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: {},
|
||||
});
|
||||
@@ -113,24 +294,8 @@ const config = ref({});
|
||||
const avatarUrl = ref('');
|
||||
const features = ref({});
|
||||
|
||||
function onSidebarClose() {
|
||||
sidebar.value.close();
|
||||
}
|
||||
|
||||
const SIDEBAR_GROUPS = Object.freeze({
|
||||
BACKUP: 'backup',
|
||||
EMAIL: 'email',
|
||||
SYSTEM: 'system',
|
||||
USERS: 'users'
|
||||
});
|
||||
|
||||
const activeSidebarGroups = ref({});
|
||||
function onToggleGroup(group) {
|
||||
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const v = location.hash;
|
||||
const v = window.location.hash.split('?')[0];
|
||||
|
||||
if (v === VIEWS.APPS) {
|
||||
view.value = VIEWS.APPS;
|
||||
@@ -162,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) {
|
||||
@@ -209,13 +376,13 @@ ProfileModel.onChange(ProfileModel.KEYS.AVATAR, (value) => {
|
||||
|
||||
async function refreshProfile() {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
profile.value = result;
|
||||
}
|
||||
|
||||
async function refreshConfigAndFeatures() {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
const currentVersion = localStorage.getItem('version');
|
||||
if (currentVersion === null) {
|
||||
@@ -224,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;
|
||||
@@ -231,50 +401,86 @@ 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
|
||||
}
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 576);
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
const [error, result] = await provisionModel.status();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
if (redirectIfNeeded(result, 'dashboard')) return; // redirected to some other view...
|
||||
|
||||
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.hash = `/${VIEWS.PROFILE}?setup2fa`;
|
||||
|
||||
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(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -284,74 +490,14 @@ onMounted(async () => {
|
||||
<Notification />
|
||||
<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" ref="sidebar">
|
||||
<a href="#/" class="sidebar-logo" @click="onSidebarClose()">
|
||||
<img :src="avatarUrl" :alt="(config.cloudronName || 'Cloudron') + ' icon'" width="40" height="40"/> {{ config.cloudronName || 'Cloudron' }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPS || view === VIEWS.APP }" :href="VIEWS.APPS" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPSTORE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPSTORE" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
|
||||
<hr/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOMAINS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.DOMAINS" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup(SIDEBAR_GROUPS.USERS)"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.USERS] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.USERS]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USERS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.USERS" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.GROUPS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.GROUPS" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" v-show="profile.isAtLeastAdmin" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> LDAP</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" v-show="profile.isAtLeastAdmin" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> OpenID</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup(SIDEBAR_GROUPS.EMAIL)"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.EMAIL] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.EMAIL]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILBOXES }" :href="VIEWS.MAILBOXES" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILINGLISTS }" :href="VIEWS.MAILINGLISTS" @click="onSidebarClose()"><i class="fa fa-fw-solid fa-envelopes-bulk"></i> {{ $t('email.incoming.mailinglists.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('emails.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.NETWORK }" v-show="profile.isAtLeastAdmin" :href="VIEWS.NETWORK" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.VOLUMES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.VOLUMES" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.BACKUP)"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.BACKUP] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.BACKUP]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.BACKUP_SITES }" :href="VIEWS.BACKUP_SITES" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APP_ARCHIVE }" :href="VIEWS.APP_ARCHIVE" @click="onSidebarClose()"><i class="fa fa-fw fa-grip"></i> {{ $t('backups.archives.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPEARANCE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPEARANCE" @click="onSidebarClose()"><i class="fa fa-pen-ruler fa-fw"></i> {{ $t('appearance.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.SYSTEM)"><i class="fa fa-server fa-fw"></i> {{ $t('system.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> Docker</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVICES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVICES" @click="onSidebarClose()"><i class="fa fa-diagram-project fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_EVENTLOG }" :href="VIEWS.SYSTEM_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_UPDATE }" :href="VIEWS.SYSTEM_UPDATE" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_SETTINGS }" :href="VIEWS.SYSTEM_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('system.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<hr v-show="profile.isAtLeastAdmin"/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVER }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVER" @click="onSidebarClose()"><i class="fa fa-microchip fa-fw"></i> {{ $t('server.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.CLOUDRON_ACCOUNT }" v-show="profile.isAtLeastOwner" :href="VIEWS.CLOUDRON_ACCOUNT" @click="onSidebarClose()"><i class="fa fa-crown fa-fw"></i> {{ $t('settings.appstoreAccount.title') }}</a>
|
||||
</div>
|
||||
</SideBar>
|
||||
<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>
|
||||
@@ -373,6 +519,7 @@ onMounted(async () => {
|
||||
<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" />
|
||||
@@ -389,112 +536,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.pankow-sidebar {
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
/* width is optimized for english */
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item:hover {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<script setup>
|
||||
|
||||
import { computed, useTemplateRef,ref } from 'vue';
|
||||
import { Menu, Button, ButtonGroup } from '@cloudron/pankow';
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
return visibleActions.filter(a => a.quickAction);
|
||||
});
|
||||
|
||||
const visibleActionCount = computed(() => {
|
||||
return props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator).length;
|
||||
});
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const menuElement = useTemplateRef('menuElement');
|
||||
function onMenu(event) {
|
||||
isMenuOpen.value = true;
|
||||
menuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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 && 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 }"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: end;
|
||||
min-height: 31px;
|
||||
align-items: center;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background-color: white;
|
||||
color: var(--pankow-color-text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button:hover {
|
||||
color: var(--pankow-color-primary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background: var(--pankow-color-background);
|
||||
color: var(--pankow-color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hide-on-touch {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .menu-action,
|
||||
tr:hover .menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .quick-action-group,
|
||||
tr:hover .quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,10 +5,11 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import TokensModel from '../models/TokensModel.js';
|
||||
|
||||
@@ -27,6 +28,15 @@ const columns = {
|
||||
label: t('profile.apiTokens.name'),
|
||||
sort: true
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
lastUsedTime: {
|
||||
label: t('profile.apiTokens.lastUsed'),
|
||||
sort(a, b) {
|
||||
@@ -35,36 +45,28 @@ const columns = {
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
actions: {
|
||||
width: '55px',
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(apiToken, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(apiToken) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRevokeToken.bind(null, apiToken),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!tokenName.value) return false;
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) return false;
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiTokens() {
|
||||
const [error, tokens] = await tokensModel.list();
|
||||
@@ -74,7 +76,7 @@ async function refreshApiTokens() {
|
||||
}
|
||||
|
||||
async function onSubmitAddApiToken(){
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
const scope = { '*': tokenScope.value };
|
||||
const allowedIpRanges = tokenAllowedIpRanges.value;
|
||||
@@ -96,6 +98,7 @@ function onReset() {
|
||||
tokenScope.value = 'rw';
|
||||
tokenAllowedIpRanges.value = '';
|
||||
tokenAllowedIpRangesError.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
@@ -125,13 +128,12 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createApiToken.title')"
|
||||
:confirm-label="addedToken ? '' : $t('main.action.add')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="isFormValid"
|
||||
confirm-style="primary"
|
||||
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@@ -141,8 +143,8 @@ onMounted(async () => {
|
||||
<div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedToken">
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="apiTokenName">{{ $t('profile.createApiToken.name') }}</label>
|
||||
<TextInput id="apiTokenName" v-model="tokenName" required/>
|
||||
@@ -183,22 +185,20 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
|
||||
<template #lastUsedTime="apiToken">
|
||||
<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">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''" v-tooltip="apiToken.allowedIpRanges">{{ apiToken.allowedIpRanges }}</span>
|
||||
<template #allowedIpRanges="{ item:apiToken }">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-else>{{ '*' }}</span>
|
||||
</template>
|
||||
<template #actions="apiToken">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(apiToken, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<template #actions="{ item:apiToken }">
|
||||
<ActionBar :actions="createActionMenu(apiToken)" />
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
import { Dialog, FormGroup, TextInput, PasswordInput, Checkbox } from '@cloudron/pankow';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import { s3like, mountlike, parseFullBackupPath } from '../utils.js';
|
||||
import BackupProviderForm from './BackupProviderForm.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js';
|
||||
@@ -10,35 +10,42 @@ import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LIN
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const backupConfigInput = useTemplateRef('backupConfigInput');
|
||||
const appId = ref('');
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const providerConfig = ref({});
|
||||
const provider = ref('');
|
||||
const remotePath = ref('');
|
||||
const fullPath = ref('');
|
||||
const format = ref('');
|
||||
const encrypted = ref(false);
|
||||
const encryptionPasswordHint = ref('');
|
||||
const encryptionPassword = ref('');
|
||||
const encryptedFilenames = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
let backupPath = remotePath.value;
|
||||
const config = {};
|
||||
|
||||
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
|
||||
|
||||
// only set provider specific fields, this will clear them in the db
|
||||
if (s3like(provider.value)) {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.accessKeyId = providerConfig.value.accessKeyId;
|
||||
config.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
config.prefix = prefix;
|
||||
|
||||
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
|
||||
|
||||
@@ -83,9 +90,12 @@ async function onSubmit() {
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'synology-c2-objectstorage') {
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.prefix = prefix;
|
||||
config.noHardlinks = !providerConfig.value.useHardlinks;
|
||||
config.mountOptions = {};
|
||||
|
||||
@@ -113,21 +123,19 @@ async function onSubmit() {
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
}
|
||||
} else if (provider.value === 'filesystem') {
|
||||
const parts = remotePath.value.split('/');
|
||||
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
|
||||
config.backupDir = parts.join('/'); // this is dirname()
|
||||
config.backupDir = prefix;
|
||||
} else if (provider.value === 'gcs') {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.prefix = providerConfig.value.prefix;
|
||||
config.projectId = providerConfig.value.projectId;
|
||||
config.credentials = providerConfig.value.credentials;
|
||||
config.prefix = prefix;
|
||||
}
|
||||
|
||||
const data = {
|
||||
format: format.value,
|
||||
provider: provider.value,
|
||||
config: config,
|
||||
remotePath: backupPath
|
||||
config,
|
||||
remotePath
|
||||
};
|
||||
|
||||
if (encrypted.value) {
|
||||
@@ -188,37 +196,65 @@ function onBackupConfigChanged(event) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(result.target.result); // 'provider', 'config', 'limits', 'format', 'remotePath', 'encrypted', 'encryptedFilenames'
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
data.remotePath = `${data.config.backupDir}/${data.remotePath}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unable to parse backup config', e);
|
||||
return;
|
||||
}
|
||||
|
||||
provider.value = data.provider;
|
||||
remotePath.value = data.remotePath;
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
fullPath.value = data.config.prefix ? `${data.config.backupDir}/${data.config.prefix}/${data.remotePath}` : `${data.config.backupDir}/${data.remotePath}`;
|
||||
} else if (data.provider === 'mountpoint') {
|
||||
fullPath.value = data.config.prefix ? `${data.config.mountPoint}/${data.config.prefix}/${data.remotePath}` : `${data.config.mountPoint}/${data.remotePath}`;
|
||||
} else {
|
||||
fullPath.value = data.config.prefix ? `${data.config.prefix}/${data.remotePath}` : data.remotePath;
|
||||
}
|
||||
format.value = data.format;
|
||||
encrypted.value = !!data.encrypted;
|
||||
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = data.encryptedFilenames;
|
||||
|
||||
// translate for BackupProviderForm flattened object
|
||||
providerConfig.value = {};
|
||||
providerConfig.value.useHardlinks = !data.config.noHardlinks;
|
||||
providerConfig.value.prefix = data.config.prefix;
|
||||
providerConfig.value.chown = !!data.config.chown;
|
||||
providerConfig.value.preserveAttributes = data.config.preserveAttributes;
|
||||
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
|
||||
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
|
||||
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
|
||||
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
|
||||
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
|
||||
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
|
||||
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
|
||||
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
|
||||
providerConfig.value.mountOptionPrivateKey = '';
|
||||
for (const [key, value] of Object.entries(data.config)) {
|
||||
switch (key) {
|
||||
case 'noHardlinks':
|
||||
case 'chown':
|
||||
case 'preserveAttributes':
|
||||
// not really used for importing
|
||||
break;
|
||||
case 'projectId':
|
||||
case 'credentials':
|
||||
// gcs fields which should be set by user by uploading json
|
||||
break;
|
||||
case 'mountOptions': // providerConfig uses a flattened format of config.mountOptions
|
||||
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
|
||||
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
|
||||
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
|
||||
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
|
||||
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
|
||||
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
|
||||
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
|
||||
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
|
||||
providerConfig.value.mountOptionPrivateKey = '';
|
||||
break;
|
||||
case 'accessKeyId': // s3
|
||||
case 'secretAccessKey': // s3
|
||||
case 'bucket': // s3, gcs
|
||||
case 'prefix': // s3, gcs
|
||||
case 'signatureVersion': // s3
|
||||
case 'endpoint': // s3
|
||||
case 'region': // s3
|
||||
case 'acceptSelfSignedCerts': // s3
|
||||
case 's3ForcePathStyle': // s3
|
||||
providerConfig.value[key] = value;
|
||||
break;
|
||||
default:
|
||||
console.log('unhandled key when importing config file:', key);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
};
|
||||
|
||||
reader.readAsText(event.target.files[0]);
|
||||
@@ -228,6 +264,10 @@ function onUploadBackupConfig() {
|
||||
backupConfigInput.value.click();
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
async open(id) {
|
||||
appId.value = id;
|
||||
@@ -235,13 +275,15 @@ defineExpose({
|
||||
formError.value = {};
|
||||
provider.value = '';
|
||||
providerConfig.value = {};
|
||||
remotePath.value = '';
|
||||
fullPath.value = '';
|
||||
encrypted.value = false;
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
encryptionPasswordHint.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -253,7 +295,7 @@ defineExpose({
|
||||
|
||||
<Dialog ref="dialog" :title="$t('app.importBackupDialog.title')"
|
||||
:confirm-label="$t('app.importBackupDialog.importAction')"
|
||||
:confirm-active="!busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@@ -273,14 +315,14 @@ defineExpose({
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<!-- remotePath contains the prefix as well -->
|
||||
<FormGroup>
|
||||
<label for="inputRemotePath">{{ $t('app.importBackupDialog.remotePath') }} <sup><a href="https://docs.cloudron.io/backups/#import-app-backup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="inputRemotePath" v-model="remotePath" required />
|
||||
<TextInput id="inputRemotePath" v-model="fullPath" required />
|
||||
</FormGroup>
|
||||
|
||||
<BackupProviderForm ref="form"
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
<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 } 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 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'),
|
||||
DETAILS: Symbol('details'),
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
@@ -28,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');
|
||||
@@ -36,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);
|
||||
@@ -65,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
|
||||
@@ -78,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([]);
|
||||
|
||||
@@ -87,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;
|
||||
|
||||
@@ -98,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) {
|
||||
@@ -106,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 = {
|
||||
@@ -120,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;
|
||||
|
||||
@@ -145,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;
|
||||
@@ -159,9 +182,8 @@ async function onSubmit(overwriteDns) {
|
||||
formError.value.port = match ? parseInt(match[1]) : null;
|
||||
} else if (error.status === 409 && error.body.message.indexOf('primary location') !== -1) {
|
||||
formError.value.location = error.body.message;
|
||||
} else if (error.status === 412) {
|
||||
formError.value.generic = error.body.message;
|
||||
} else {
|
||||
formError.value.generic = error.body?.message || `Error installing app. Status code: ${error.status} . ${error.body}`;
|
||||
console.error('Failed to install:', error);
|
||||
}
|
||||
}
|
||||
@@ -173,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();
|
||||
@@ -200,18 +222,21 @@ function onScreenshotNext() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: async function(a, appCountExceeded, domainList) {
|
||||
open: async function(pd, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
step.value = STEP.DETAILS;
|
||||
app.value = a;
|
||||
step.value = STEP.LOADING;
|
||||
formError.value = {};
|
||||
|
||||
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;
|
||||
@@ -222,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) {
|
||||
@@ -235,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;
|
||||
@@ -243,7 +268,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
|
||||
step.value = STEP.DETAILS;
|
||||
dialog.value.open();
|
||||
},
|
||||
close() {
|
||||
@@ -254,18 +279,20 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="true" style="width: unset; min-width: min(450px, 95%)">
|
||||
<div class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="step !== STEP.LOADING" style="width: unset;" :style="{ 'min-width': step !== STEP.LOADING ? 'min(450px, 95%)' : 'unset' }">
|
||||
<div v-if="step === STEP.LOADING" class="app-install-dialog-body">
|
||||
<Spinner class="pankow-spinner-large"/>
|
||||
</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">
|
||||
@@ -280,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>
|
||||
@@ -301,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>
|
||||
@@ -344,7 +372,6 @@ defineExpose({
|
||||
|
||||
.app-install-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
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';
|
||||
import AppPasswordsModel from '../models/AppPasswordsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -29,27 +29,32 @@ const columns = {
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
label: t('main.table.created'),
|
||||
hideMobile: true,
|
||||
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: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(appPassword, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(appPassword) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRemove.bind(null, appPassword),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
// new dialog props
|
||||
@@ -57,53 +62,75 @@ 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 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;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!passwordName.value) return false;
|
||||
if (!identifier.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
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) {
|
||||
@@ -158,12 +185,12 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-active="addedPassword || isValid"
|
||||
: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')"
|
||||
@@ -172,19 +199,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">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
</FormGroup>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<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" />
|
||||
</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>
|
||||
@@ -208,11 +243,15 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
|
||||
<template #actions="password">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(password, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
@@ -38,39 +38,43 @@ const accessRestriction = ref({
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (busy.value) return false;
|
||||
if (!upstreamUri.value) return false;
|
||||
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
let iconFile = 'src';
|
||||
function onIconChanged(file) {
|
||||
iconFile = file;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
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
|
||||
@@ -127,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();
|
||||
@@ -134,6 +139,8 @@ defineExpose({
|
||||
groups.value = result;
|
||||
|
||||
applinkDialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,16 +154,16 @@ defineExpose({
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
@alternate="onRemove()"
|
||||
>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid" />
|
||||
<input style="display: none;" type="submit" />
|
||||
|
||||
<p class="has-error" v-show="error.generic">{{ error.generic }}</p>
|
||||
|
||||
@@ -172,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>
|
||||
|
||||
@@ -183,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> -->
|
||||
@@ -192,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>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { inject, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup } from '@cloudron/pankow';
|
||||
import ApplinkDialog from './ApplinkDialog.vue';
|
||||
import Section from './Section.vue';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
|
||||
function onAddExternalLink() {
|
||||
applinkDialog.value.open();
|
||||
}
|
||||
|
||||
function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
|
||||
<Section :title="$t('dashboard.title')">
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('externallinks.label') }}</label>
|
||||
<div>{{ $t('externallinks.description') }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; position: relative; align-items: center">
|
||||
<Button tool plain @click="onAddExternalLink()" :disabled="!features.branding">{{ $t('main.action.add') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -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';
|
||||
@@ -103,7 +121,7 @@ defineExpose({
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
|
||||
<div class="info-value">{{ backup.label }}</div>
|
||||
<div class="info-value">{{ backup.label || 'Not set'}}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
@@ -124,7 +142,7 @@ defineExpose({
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s)</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s) | {{ backup.appCount }} app(s) </div>
|
||||
<div v-else class="info-value">{{ prettyFileSize(backup.stats.upload.size) }} | {{ backup.stats.upload.fileCount }} file(s)</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
@@ -132,27 +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'">
|
||||
<br/>
|
||||
<div>{{ $t('backups.backupDetails.list', { appCount: backup.appCount }) }}:</div>
|
||||
<br/>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -92,6 +92,10 @@ watch(provider, (newProvider) => {
|
||||
if (parseInt(providerConfig.value.downloadConcurrency) < 30) providerConfig.value.downloadConcurrency = 30;
|
||||
if (parseInt(providerConfig.value.syncConcurrency) < 20) providerConfig.value.syncConcurrency = 20;
|
||||
if (parseInt(providerConfig.value.copyConcurrency) < 500) providerConfig.value.downloadConcurrency = 500;
|
||||
} else if (newProvider === 'cifs') {
|
||||
providerConfig.value.mountOptionSeal = true;
|
||||
} else if (newProvider === 'sshfs') {
|
||||
providerConfig.value.mountOptionPort = 23;
|
||||
} else if (newProvider === 'gcs') {
|
||||
providerConfig.value.credentials = {
|
||||
client_email: '',
|
||||
@@ -100,6 +104,12 @@ watch(provider, (newProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(format, (newFormat) => {
|
||||
if (newFormat === 'rsync') {
|
||||
if (provider.value === 'filesystem' || mountlike(provider.value)) providerConfig.value.useHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!providerConfig.value.mountOptionPrivateKey) return;
|
||||
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
|
||||
@@ -125,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/>
|
||||
<div v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></div>
|
||||
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
@@ -162,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>
|
||||
@@ -189,13 +193,13 @@ onMounted(async () => {
|
||||
<!-- Filesystem -->
|
||||
<FormGroup v-if="provider === 'filesystem' && !importOnly">
|
||||
<label for="backupDirInput">{{ $t('backups.configureBackupStorage.localDirectory') }}</label>
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="Directory for backups" required />
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="/opt/backups" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS/UpCloud/B2/R2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
|
||||
<!-- Endpoint - S3/Minio/SOS/GCS/UpCloud/B2/R2/C2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2' || provider === 'synology-c2-objectstorage'">
|
||||
<label for="endpointInput">{{ $t('backups.configureBackupStorage.s3Endpoint') }}</label>
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="URL" required />
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="https://s3endpoint.example.com" required />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="provider === 'minio' || provider === 's3-v4-compat'" v-model="providerConfig.acceptSelfSignedCerts" :label="$t('backups.configureBackupStorage.acceptSelfSignedCerts')"/>
|
||||
@@ -205,12 +209,14 @@ onMounted(async () => {
|
||||
<TextInput id="bucketInput" v-model="providerConfig.bucket" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- when importing/restoring, the user enters a fullPath which contains the prefix -->
|
||||
<FormGroup v-if="provider !== 'filesystem' && !importOnly">
|
||||
<label for="prefixInput">{{ $t('backups.configureBackupStorage.prefix') }}</label>
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="Prefix for backup file names" />
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="my-backups" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.prefixHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS -->
|
||||
<!-- Region Selector -->
|
||||
<FormGroup v-if="
|
||||
provider === 's3' ||
|
||||
provider === 'digitalocean-spaces' ||
|
||||
@@ -241,7 +247,8 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup v-if="provider === 's3-v4-compat'">
|
||||
<label for="s3v4CompatRegionInput">{{ $t('backups.configureBackupStorage.region') }}</label>
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" placeholder="Leave empty to use us-east-1 as default" />
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.regionHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
@@ -258,7 +265,8 @@ onMounted(async () => {
|
||||
<input type="file" id="gcsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcsKeyChange"/>
|
||||
<label for="gcsKeyInput">{{ $t('backups.configureBackupStorage.gcsServiceKey') }}{{ providerConfig.projectId ? ` - project: ${providerConfig.projectId}` : '' }}</label>
|
||||
<InputGroup>
|
||||
<TextInput readonly required style="flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service account key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<input style="display: none" :value="providerConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service account key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-show="gcsFileParseError">{{ gcsFileParseError }}</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const step = ref('storage');
|
||||
const newSiteId = ref('');
|
||||
const name = ref('');
|
||||
@@ -101,6 +100,9 @@ async function onSubmit() {
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'synology-c2-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
data.prefix = providerConfig.value.prefix;
|
||||
@@ -122,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') {
|
||||
@@ -227,10 +229,10 @@ function onCancel() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
const isValid = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -289,7 +291,7 @@ defineExpose({
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
// checkValidity();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -299,9 +301,9 @@ defineExpose({
|
||||
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
|
||||
<div>
|
||||
<div v-if="step === 'storage'">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @change="checkValidity()">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid"/>
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
|
||||
@@ -378,7 +380,7 @@ defineExpose({
|
||||
|
||||
<div style="display: flex; gap: 6px; align-items: end;">
|
||||
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
|
||||
<Button primary :disabled="busy || !isValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
<Button primary :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -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, regionName } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -205,15 +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>
|
||||
<span v-if="site.provider === 'filesystem'">{{ site.config.backupDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'disk' || site.provider === 'ext4' || site.provider === 'xfs' || site.provider === 'mountpoint'">{{ site.config.mountOptions.diskPath || site.config.mountPoint }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'sshfs'">{{ site.config.mountOptions.host }}:{{ site.config.mountOptions.remoteDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 's3'">{{ site.config.region + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'minio'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'gcs'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else>{{ regionName(site.provider, site.config.endpoint) + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
</div>
|
||||
<div>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
|
||||
@@ -35,8 +35,20 @@ async function onSubmit() {
|
||||
if (includeExclude.value === 'everything') {
|
||||
contents = null;
|
||||
} else if (includeExclude.value === 'exclude') {
|
||||
if (contentExclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Exclude at least one content item or select Everything';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { exclude: contentExclude.value };
|
||||
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
|
||||
} else if (includeExclude.value === 'include') {
|
||||
if (contentInclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Include at least one content item';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
@@ -60,6 +72,9 @@ defineExpose({
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
includeExclude.value = 'everything';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
|
||||
@@ -68,7 +83,7 @@ defineExpose({
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'Platform',
|
||||
label: 'System & email',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
@@ -86,8 +101,6 @@ defineExpose({
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
} else {
|
||||
includeExclude.value = 'everything';
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -120,12 +133,13 @@ defineExpose({
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div class="error-label" v-if="formError.includeExclude">{{ formError.includeExclude }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, parseSchedule } from '../utils.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -18,7 +18,7 @@ const days = ref([]);
|
||||
const hours = ref([]);
|
||||
const configureRetention = ref(''); // this is 'name' and not 'id' of backupRetentions because SingleSelect needs strings
|
||||
const isConfigureValid = computed(() => {
|
||||
return !!days.value.length && !!hours.value.length;
|
||||
return scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -67,6 +67,8 @@ defineExpose({
|
||||
site.value = s;
|
||||
busy.value = false;
|
||||
formError.value = false;
|
||||
days.value = [];
|
||||
hours.value = [];
|
||||
|
||||
const currentRetentionString = JSON.stringify(site.value.retention);
|
||||
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
|
||||
@@ -76,16 +78,9 @@ defineExpose({
|
||||
scheduleType.value = 'never';
|
||||
} else {
|
||||
scheduleType.value = 'pattern';
|
||||
|
||||
const tmp = site.value.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
const tmpDays = tmp[5].split(',');
|
||||
|
||||
if (tmpDays[0] === '*') days.value = cronDays.map((day) => { return day.id; });
|
||||
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
|
||||
|
||||
if (tmpHours[0] === '*') hours.value = cronHours.map(h => h.id);
|
||||
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
|
||||
const result = parseSchedule(site.value.schedule);
|
||||
days.value = result.days; // Array of cronDays.id
|
||||
hours.value = result.hours; // Array of cronHours.id
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
@@ -25,7 +25,7 @@ async function onNameSave(newName) {
|
||||
|
||||
const [error] = await brandingModel.setName(newName);
|
||||
savingName.value = false;
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
name.value = newName;
|
||||
}
|
||||
@@ -87,7 +87,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave"/>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -12,23 +12,33 @@ const dialog = useTemplateRef('dialog');
|
||||
const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!password.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
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,11 +56,14 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
async open(method = 'totp') {
|
||||
twoFAMethod.value = method;
|
||||
password.value = '';
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -58,25 +71,25 @@ 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"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required id="passwordInput" />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -75,10 +75,4 @@ onMounted(async () => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disks-last-updated {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Button, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import { prettyDecimalSize, prettyDate } from '@cloudron/pankow/utils';
|
||||
import { getColor } from '../utils.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -14,16 +14,20 @@ const props = defineProps({
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const percent = ref(0);
|
||||
const contents = ref([]);
|
||||
const speed = ref(-1);
|
||||
const contents = ref([]); // cached
|
||||
const speed = ref(-1); // cached
|
||||
const ts = ref(0); // cached
|
||||
const highlight = ref(null);
|
||||
const showingCachedValue = ref(false);
|
||||
|
||||
let eventSource = null;
|
||||
|
||||
async function refresh() {
|
||||
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;
|
||||
@@ -33,10 +37,16 @@ async function refresh() {
|
||||
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
cache[props.filesystem.filesystem] = { contents: contents.value, speed: speed.value, ts: ts.value };
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
|
||||
eventSource.close();
|
||||
} else if (payload.type === 'progress') {
|
||||
percent.value = payload.percent;
|
||||
@@ -64,9 +74,33 @@ async function onExpand() {
|
||||
|
||||
isExpanded.value = true;
|
||||
|
||||
refresh();
|
||||
getUsage();
|
||||
}
|
||||
|
||||
function loadFromCache() {
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
const entry = cache[props.filesystem.filesystem];
|
||||
|
||||
if (!entry) return;
|
||||
|
||||
if (Date.now() - entry.ts < 60 * 60 * 1000) { // 1 hour old
|
||||
contents.value = entry.contents;
|
||||
speed.value = entry.speed;
|
||||
percent.value = 100;
|
||||
ts.value = entry.ts;
|
||||
isExpanded.value = true;
|
||||
showingCachedValue.value = true;
|
||||
} else {
|
||||
delete cache[props.filesystem.filesystem]; // remove obsolete entry
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFromCache();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) eventSource.close();
|
||||
});
|
||||
@@ -77,10 +111,12 @@ onUnmounted(() => {
|
||||
<div class="disk-item">
|
||||
<div class="disk-item-title">
|
||||
<div>{{ filesystem.mountpoint }} <small class="text-muted">{{ filesystem.type }}</small></div>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="refresh()"/>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="getUsage()"/>
|
||||
</div>
|
||||
<div class="disk-item-size-and-speed">
|
||||
<div>{{ prettyDecimalSize(filesystem.available) }} free of {{ prettyDecimalSize(filesystem.size) }} total</div>
|
||||
<div>{{ prettyDecimalSize(filesystem.used) }} used of {{ prettyDecimalSize(filesystem.size) }} total
|
||||
<span v-if="showingCachedValue">(Last updated {{ prettyDate(ts) }})</span>
|
||||
</div>
|
||||
<div v-if="speed !== -1">I/O Rate: {{ prettyDecimalSize(speed * 1000 * 1000) }}/sec</div>
|
||||
</div>
|
||||
<div v-if="isExpanded" @mouseout="highlight = null">
|
||||
@@ -141,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;
|
||||
}
|
||||
|
||||
@@ -189,7 +225,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
tr.highlight {
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,8 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, Menu, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import { Button, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import DockerRegistryDialog from '../components/DockerRegistryDialog.vue';
|
||||
import DockerRegistriesModel from '../models/DockerRegistriesModel.js';
|
||||
@@ -28,25 +29,23 @@ const columns = {
|
||||
label: t('dockerRegistries.username'),
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
actions: {
|
||||
width: '100px',
|
||||
}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(registry, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(registry) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-pencil-alt',
|
||||
label: t('main.action.edit'),
|
||||
quickAction: true,
|
||||
action: onEditOrAdd.bind(null, registry),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
quickAction: true,
|
||||
action: onRemove.bind(null, registry),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const features = inject('features');
|
||||
@@ -94,7 +93,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('dockerRegistries.title')" :title-badge="!features.privateDockerRegistry ? 'Upgrade' : ''">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
<DockerRegistryDialog ref="dialog" @success="refresh()"/>
|
||||
|
||||
@@ -106,10 +104,8 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
|
||||
<template #actions="registry">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(registry, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<template #actions="{ item:registry }">
|
||||
<ActionBar :actions="createActionMenu(registry)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -37,7 +37,7 @@ const password = ref('');
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Button } from '@cloudron/pankow';
|
||||
import { getTextFromFile } from '../utils.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -31,7 +31,7 @@ const dnsConfig = ref(DomainsModel.createEmptyConfig());
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -99,6 +99,10 @@ function onKeyFileChange() {
|
||||
keyFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open(d) {
|
||||
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
|
||||
@@ -148,7 +152,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="domainInput">{{ $t('domains.domainDialog.domain') }}</label>
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing ? true : undefined" required />
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing" :required="!editing" />
|
||||
</FormGroup>
|
||||
|
||||
<DomainProviderForm v-model:provider="provider" v-model:dns-config="dnsConfig" v-model:tls-provider="tlsProvider" v-model:zone-name="zoneName" v-model:custom-nameservers="customNameservers" :domain="domain" :show-advanced="showAdvanced" />
|
||||
|
||||
@@ -53,21 +53,12 @@ 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.accessToken = '';
|
||||
dnsConfig.value.apiKey = '';
|
||||
dnsConfig.value.apikey = '';
|
||||
dnsConfig.value.appKey = '';
|
||||
dnsConfig.value.appSecret = '';
|
||||
dnsConfig.value.apiPassword = '';
|
||||
dnsConfig.value.apiSecret = '';
|
||||
@@ -87,8 +78,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,7 +127,7 @@ 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>
|
||||
@@ -152,7 +149,8 @@ function onGcdnsFileInputChange(event) {
|
||||
<input type="file" id="gcdnsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcdnsFileInputChange"/>
|
||||
<label class="control-label">{{ $t('domains.domainDialog.gcdnsServiceAccountKey') }}{{ dnsConfig.projectId ? ` - project: ${dnsConfig.projectId}` : '' }}</label>
|
||||
<InputGroup>
|
||||
<TextInput readonly required style="flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service account key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
|
||||
<input style="display: none" :value="dnsConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; flex-grow: 1" v-model="dnsConfig.credentials.client_email" placeholder="Service account key" onclick="getElementById('gcdnsKeyFileInput').click();"/>
|
||||
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcdnsKeyFileInput').click();"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-show="gcdnsFileParseError">{{ gcdnsFileParseError }}</div>
|
||||
@@ -314,14 +312,15 @@ function onGcdnsFileInputChange(event) {
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label for="zoneNameInput">{{ $t('domains.domainDialog.zoneName') }} <sup><a href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="zoneNameInput" v-model="zoneName" />
|
||||
<small class="helper-text">{{ $t('domains.domainDialog.zoneNamePlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
<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>
|
||||
|
||||
@@ -14,6 +14,7 @@ const props = defineProps({
|
||||
multiline: { type: Boolean, default: false },
|
||||
markdown: { type: Boolean, default: false },
|
||||
rows: { type: Number, default: 2 },
|
||||
maxlength: { type: Number, default: -1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
@@ -56,8 +57,8 @@ function cancel() {
|
||||
<FormGroup>
|
||||
<label>{{ label }} <sup v-if="helpUrl"><a :href="helpUrl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="editing" style="display: flex; align-items: center; gap: 6px">
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving" :required="required ? true : null"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null"></textarea>
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"></textarea>
|
||||
<Button tool @click="save" :disabled="saving || (required && !draftValue)">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -49,7 +49,7 @@ const autoCreate = ref(false);
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -258,7 +258,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isFormValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
@@ -15,15 +15,18 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (email.value && !isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +59,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +78,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required/>
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,8 +399,8 @@ async function onRestartApp() {
|
||||
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('app.repair.recovery.restartAction'),
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,17 +40,27 @@ function renderTooltip(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, labelColors } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
// datapoints are in sync with the indexing of body
|
||||
const { title, body, labelColors, dataPoints } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
if (body) {
|
||||
const titleLines = title || [];
|
||||
const bodyLines = body.map(item => item.lines);
|
||||
const bodyLines = body.map(item => { return { label: item.lines }; });
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
|
||||
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = labelColors[i];
|
||||
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
|
||||
// first amend the value so we know the dataPoints index, then sort and render
|
||||
bodyLines.forEach((body, i) => {
|
||||
body.value = dataPoints[i].parsed?.y || 0;
|
||||
body.color = labelColors[i].borderColor;
|
||||
});
|
||||
bodyLines.sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
});
|
||||
bodyLines.slice(0, 5).forEach(body => {
|
||||
innerHtml += `<div style="color: ${body.color}" class="graphs-tooltip-item">${body.label}</div>`;
|
||||
});
|
||||
|
||||
if (bodyLines.length > 5) innerHtml += '<div class="graphs-tooltip-item graphs-tooltip-ellipsis">⋯</div>';
|
||||
|
||||
tooltipElem.value.innerHTML = innerHtml;
|
||||
}
|
||||
@@ -340,13 +350,13 @@ defineExpose({
|
||||
.graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -369,8 +379,33 @@ defineExpose({
|
||||
border-right: 1px var(--pankow-color-primary) solid;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item {
|
||||
padding: 2px 0px;
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
background: var(--pankow-color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs-tooltip-title {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item:last-of-type {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-ellipsis {
|
||||
font-size: 9px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,9 @@ const group = ref(null);
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const name = ref('');
|
||||
const users = ref([]);
|
||||
const userIds = ref([]);
|
||||
const allUsers = ref([]);
|
||||
const apps = ref([]);
|
||||
const appIds = ref([]);
|
||||
const allApps = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -29,7 +29,7 @@ async function onSubmit() {
|
||||
formError.value = {};
|
||||
|
||||
if (group.value) {
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -37,7 +37,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
} else {
|
||||
const [error] = await groupsModel.add(name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.add(name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,13 +63,13 @@ defineExpose({
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.label = (u.username || u.email));
|
||||
allUsers.value = result;
|
||||
users.value = g ? g.userIds : [];
|
||||
userIds.value = g ? g.userIds : [];
|
||||
|
||||
[error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(a => a.label = (a.label || a.fqdn));
|
||||
allApps.value = result;
|
||||
apps.value = g ? g.appIds : [];
|
||||
appIds.value = g ? g.appIds : [];
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
@@ -103,13 +103,14 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="usersInput">{{ $t('users.group.users') }}</label>
|
||||
<div v-if="group?.source"><span v-for="user of groupEdit.selectedUsers" :key="user.id"> {{ (user.username || user.email) }}</span></div>
|
||||
<MultiSelect v-else v-model="users" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
<!-- membership of external groups cannot be edited -->
|
||||
<div v-if="group?.source"><span v-for="userId of userIds" :key="userId" style="padding-right: 5px">{{ allUsers.find(u => u.id === userId)?.username || allUsers.find(u => u.id === userId)[userId]?.email }}</span></div>
|
||||
<MultiSelect v-else v-model="userIds" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
|
||||
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
<MultiSelect v-model="appIds" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -6,15 +6,14 @@ const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { Button, Popover, Icon, 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';
|
||||
|
||||
const props = defineProps(['config', 'subscription']);
|
||||
defineProps(['config', 'notificationCount']);
|
||||
|
||||
const profile = inject('profile');
|
||||
const subscription = inject('subscription');
|
||||
|
||||
const helpButton = useTemplateRef('helpButton');
|
||||
const helpPopover = useTemplateRef('helpPopover');
|
||||
@@ -24,58 +23,7 @@ function onOpenHelp(popover, event, elem) {
|
||||
}
|
||||
|
||||
const servicesModel = ServicesModel.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, 2, 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 profileModel = ProfileModel.create();
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
|
||||
@@ -85,7 +33,7 @@ function onSubscriptionRequired() {
|
||||
|
||||
const platformStatus = ref({
|
||||
message: '',
|
||||
isReady: true,
|
||||
state: '',
|
||||
});
|
||||
|
||||
let platformTimeoutId = 0;
|
||||
@@ -95,7 +43,16 @@ async function trackPlatformStatus() {
|
||||
|
||||
platformStatus.value = result;
|
||||
|
||||
if (!result.isReady) platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
if (result.state === 'starting') platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
function onShowPlatformError() {
|
||||
inputDialog.value.info({
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
title: t('main.platform.startupFailed'),
|
||||
message: platformStatus.value.message,
|
||||
});
|
||||
}
|
||||
|
||||
const description = marked.parse(t('support.help.description', {
|
||||
@@ -105,9 +62,24 @@ const description = marked.parse(t('support.help.description', {
|
||||
apiLink: 'https://docs.cloudron.io/api.html'
|
||||
}));
|
||||
|
||||
onMounted(async () => {
|
||||
if (profile.value.isAtLeastAdmin) await refresh();
|
||||
const avatarActions = [{//
|
||||
icon: 'fa-solid fa-circle-user',
|
||||
label: t('profile.title'),
|
||||
action: () => { window.location.href = '#/profile'; }
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-right-from-bracket',
|
||||
label: t('main.logout'),
|
||||
action: () => { profileModel.logout(); }
|
||||
}];
|
||||
|
||||
const avatarMenu = useTemplateRef('avatarMenu');
|
||||
function onAvatarClick(event) {
|
||||
avatarMenu.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await trackPlatformStatus();
|
||||
});
|
||||
|
||||
@@ -119,29 +91,8 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="headerbar">
|
||||
<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>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<Menu ref="avatarMenu" :model="avatarActions" />
|
||||
|
||||
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
@@ -158,17 +109,21 @@ onUnmounted(() => {
|
||||
|
||||
<div style="flex-grow: 1;"></div>
|
||||
|
||||
<div v-if="!platformStatus.isReady" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/> {{ platformStatus.message }}
|
||||
<div v-if="platformStatus.state === 'starting'" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/>{{ platformStatus.message }}
|
||||
</div>
|
||||
<div v-else-if="platformStatus.state === 'failed'" class="headerbar-info text-danger" style="cursor: pointer" @click="onShowPlatformError">
|
||||
<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" style="gap: 6px" 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>
|
||||
<div class="headerbar-action pankow-no-mobile" style="gap: 6px" 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="/#/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" href="#/profile"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -183,13 +138,14 @@ onUnmounted(() => {
|
||||
|
||||
.headerbar-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
padding: 4px 15px;
|
||||
}
|
||||
|
||||
.headerbar-action {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-text-color);
|
||||
@@ -214,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);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv4Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,19 +105,19 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIp.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<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>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from '../components/Section.vue';
|
||||
import NetworkModel from '../models/NetworkModel.js';
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv6Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,19 +105,19 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIpv6.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<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>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, ClipboardButton, Checkbox, PasswordInput, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -19,17 +19,14 @@ const ldapUrl = ref('');
|
||||
const secret = ref('');
|
||||
const allowlist = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (enabled.value) {
|
||||
if (!secret.value) return false;
|
||||
if (!allowlist.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
editError.value = {};
|
||||
@@ -65,6 +62,8 @@ onMounted(async () => {
|
||||
enabled.value = result.enabled;
|
||||
secret.value = result.secret;
|
||||
allowlist.value = result.allowlist;
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -73,9 +72,9 @@ onMounted(async () => {
|
||||
<Section :title="$t('users.exposedLdap.title')">
|
||||
<div>{{ $t('users.exposedLdap.description') }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit" :disabled="busy || !isValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<Checkbox v-model="enabled" :label="$t('users.exposedLdap.enabled')" help-url="https://docs.cloudron.io/user-directory/#ldap-directory-server"/>
|
||||
|
||||
@@ -108,6 +107,6 @@ onMounted(async () => {
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
|
||||
<br/>
|
||||
<Button :loading="busy" :disabled="!isValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
<Button :loading="busy" :disabled="!isFormValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -1,150 +1,148 @@
|
||||
<script>
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, onUnmounted, onMounted } from 'vue';
|
||||
import { Button, InputDialog, TopBar, MainLayout, ButtonGroup } from '@cloudron/pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
components: {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
InputDialog,
|
||||
MainLayout,
|
||||
TopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
logsModel: null,
|
||||
appsModel: null,
|
||||
busyRestart: false,
|
||||
showRestart: false,
|
||||
showFilemanager: false,
|
||||
showTerminal: false,
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
downloadUrl: '',
|
||||
logLines: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClear() {
|
||||
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
},
|
||||
onDownload() {
|
||||
this.logsModel.download();
|
||||
},
|
||||
async onRestartApp() {
|
||||
if (this.type !== 'app') return;
|
||||
const linesContainer = useTemplateRef('linesContainer');
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
const confirmed = await this.$refs.inputDialog.confirm({
|
||||
message: this.$t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: this.$t('main.dialog.yes'),
|
||||
rejectLabel: this.$t('main.dialog.no'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
let logsModel = null;
|
||||
const appsModel = AppsModel.create();
|
||||
let refreshInterval = 0;
|
||||
|
||||
if (!confirmed) return;
|
||||
const busyRestart = ref(false);
|
||||
const showRestart = ref(false);
|
||||
const showFilemanager = ref(false);
|
||||
const showTerminal = ref(false);
|
||||
const id = ref('');
|
||||
const name = ref('');
|
||||
const type = ref('');
|
||||
const downloadUrl = ref('');
|
||||
|
||||
this.busyRestart = true;
|
||||
function onClear() {
|
||||
while (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
}
|
||||
|
||||
const [error] = await this.appsModel.restart(this.id);
|
||||
if (error) return console.error(error);
|
||||
async function onRestartApp() {
|
||||
if (type.value !== 'app') return;
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const id = urlParams.get('id');
|
||||
if (!confirmed) return;
|
||||
|
||||
if (appId) {
|
||||
this.type = 'app';
|
||||
this.id = appId;
|
||||
this.name = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
this.type = 'task';
|
||||
this.id = taskId;
|
||||
this.name = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
this.type = 'crash';
|
||||
this.id = crashId;
|
||||
this.name = 'Crash ' + crashId;
|
||||
} else if (id) {
|
||||
if (id === 'box') {
|
||||
this.type = 'platform';
|
||||
this.id = id;
|
||||
this.name = 'Box';
|
||||
} else {
|
||||
this.type = 'service';
|
||||
this.id = id;
|
||||
this.name = 'Service ' + id;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
busyRestart.value = true;
|
||||
|
||||
this.logsModel = LogsModel.create(this.type, this.id);
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appsModel = AppsModel.create();
|
||||
busyRestart.value = false;
|
||||
}
|
||||
|
||||
const [error, app] = await this.appsModel.get(this.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
this.showFilemanager = !!app.manifest.addons.localstorage;
|
||||
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${this.name}`;
|
||||
|
||||
this.downloadUrl = this.logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
this.$refs.linesContainer.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
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;
|
||||
} else if (taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
type.value = 'crash';
|
||||
id.value = crashId;
|
||||
name.value = 'Crash ' + crashId;
|
||||
} else if (idParam) {
|
||||
if (idParam === 'box') {
|
||||
type.value = 'platform';
|
||||
id.value = idParam;
|
||||
name.value = 'Box';
|
||||
} else {
|
||||
type.value = 'service';
|
||||
id.value = idParam;
|
||||
name.value = 'Service ' + idParam;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value, { appId });
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
name.value = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
showFilemanager.value = !!app.manifest.addons.localstorage;
|
||||
showTerminal.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
showRestart.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${name.value}`;
|
||||
|
||||
downloadUrl.value = logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
refreshInterval = setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else if (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
linesContainer.value.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -84,9 +84,10 @@ onMounted(async () => {
|
||||
<div v-html="$t('email.dnsStatus.description', { emailDnsDocsLink:'https://docs.cloudron.io/email/#dns-records'})"></div>
|
||||
<br/>
|
||||
|
||||
<!-- DNS records including PTR4/PTR6 -->
|
||||
<div v-if="domainStatus.mx">
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<div>
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item">
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa-solid" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
@@ -95,17 +96,18 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ item.label }} record</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<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>
|
||||
@@ -117,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>
|
||||
@@ -131,8 +138,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="domainStatus.relay" class="record-item" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<div>
|
||||
<!-- outbound SMTP / Relay status -->
|
||||
<div v-if="domainStatus.relay" class="record-item">
|
||||
<div class="record-header" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus.relay.status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus.relay.status === 'failed',
|
||||
@@ -141,33 +149,41 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ $t('email.smtpStatus.outboundSmtp') }}</b>
|
||||
<i class="fa-solid" :class="domainStatus.relay.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen">
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen" @click.stop>
|
||||
{{ domainStatus.relay.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<!-- Blacklist -->
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item">
|
||||
<div v-if="domainStatus[key]">
|
||||
<div>
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
'fa-circle-minus text-success': domainStatus[key].status === 'skipped',
|
||||
'fa-circle-minus text-warning': domainStatus[key].status === 'skipped',
|
||||
}"></i>
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ key === 'rbl4' ? 'IPv4' : 'IPv6' }} {{ $t('email.smtpStatus.rblCheck') }}</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="item.isOpen">
|
||||
<div v-if="domainStatus[key].status !== 'failed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<div v-if="domainStatus[key].status === 'passed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div v-else-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
{{ domainStatus[key] }}
|
||||
<div v-if="domainStatus[key].servers.length" v-html="$t('email.smtpStatus.blacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
<div v-else v-html="$t('email.smtpStatus.notBlacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
|
||||
<!-- servers is only the blocked servers -->
|
||||
<br/>
|
||||
<div v-for="server in domainStatus[key].servers" :key="server.name">
|
||||
<a :href="server.removal" target="_blank">{{ server.name }}</a>
|
||||
<a :href="server.removal" target="_blank">{{ server.name }} removal link</a>
|
||||
|
||||
<span v-if="server.txtRecords.length">TXT record: {{ server.txtRecords.join('. ') }}</span>
|
||||
<span v-else>No TXT Records</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,20 +194,19 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.record-item {
|
||||
.record-header {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
color: var(--pankow-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
.record-header:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.record-details {
|
||||
padding: 10px 30px;
|
||||
padding: 10px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -209,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 {
|
||||
@@ -217,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>
|
||||
|
||||
@@ -54,7 +54,7 @@ function usesPasswordAuth(provider) {
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -97,6 +97,8 @@ async function onShowDialog() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -173,7 +175,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
|
||||
<input type="submit" style="display: none" :disabled="busy || !isFormValid"/>
|
||||
<input type="submit" style="display: none" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
|
||||
|
||||
@@ -35,8 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
name: '',
|
||||
domain: dashboardDomain.value,
|
||||
label: '@' + dashboardDomain.value,
|
||||
domain: domain.value,
|
||||
label: '@' + domain.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,15 @@ async function onRemoveAlias(index) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
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 = '';
|
||||
|
||||
@@ -80,7 +88,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -99,30 +107,23 @@ defineExpose({
|
||||
active.value = m ? m.active : true;
|
||||
enablePop3.value = m ? m.enablePop3 : false;
|
||||
storageQuotaEnabled.value = m && m.storageQuota ? true : false;
|
||||
storageQuota.value = m ? m.storageQuota : 5*1000*1000*1000;
|
||||
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Users' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users);
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users.map(u => {
|
||||
return { ...u, icon: 'fa-solid fa-user', name: u.username || u.displayName || u.email };
|
||||
}));
|
||||
|
||||
if (props.groups.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Groups' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups);
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups.map(g => {
|
||||
return { ...g, icon: 'fa-solid fa-users' };
|
||||
}));
|
||||
|
||||
if (props.apps.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Apps' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps);
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroupsAndApps.value.forEach(item => {
|
||||
if (item.appIds) {
|
||||
item.icon = 'fa-solid fa-users';
|
||||
} else if (item.username) {
|
||||
item.icon = 'fa-solid fa-user';
|
||||
item.name = item.username;
|
||||
} else {
|
||||
item.icon = 'fa-solid fa-cube';
|
||||
item.name = item.label || item.fqdn;
|
||||
}
|
||||
});
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps.map(a => {
|
||||
return { ...a, icon: 'fa-solid fa-cube', name: a.label || a.fqdn };
|
||||
}));
|
||||
|
||||
domainList.value = props.domains.map(d => {
|
||||
return {
|
||||
@@ -133,6 +134,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -143,23 +146,22 @@ defineExpose({
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title') : $t('email.addMailboxDialog.title')"
|
||||
:confirm-label="$t(mailbox ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== ''"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailbox ? true : undefined"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox"/>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailbox" :required="!mailbox"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox" :required="!mailbox"/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
@@ -167,7 +169,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name"/>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="mailbox" v-model="active" :label="$t('email.updateMailboxDialog.activeCheckbox')"/>
|
||||
@@ -192,10 +194,9 @@ defineExpose({
|
||||
<Button tool danger icon="fa-solid fa-trash-alt" @click="onRemoveAlias(index)"/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
<div style="margin-top: 5px"></div>
|
||||
<div v-if="aliases.length === 0">
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAnotherAliasAction') }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, inject } from 'vue';
|
||||
import { computed, ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import MailinglistsModel from '../models/MailinglistsModel.js';
|
||||
|
||||
@@ -21,6 +21,10 @@ const active = ref(true);
|
||||
const domainList = ref([]);
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
const memberCount = computed(() => {
|
||||
return membersText.value.split('\n').map(m => m.trim()).filter(m => m).length;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -85,6 +89,7 @@ defineExpose({
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title') : $t('email.addMailinglistDialog.title')"
|
||||
:style="{ 'min-width': '700px' }"
|
||||
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
|
||||
@@ -103,14 +108,14 @@ defineExpose({
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailinglist ? true : undefined"/>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailinglist"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailinglist"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }}</label>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }} ({{ memberCount }})</label>
|
||||
<textarea id="membersInput" v-model="membersText" rows="5"></textarea>
|
||||
<div class="error-label" v-if="formError.members">{{ formError.members }}</div>
|
||||
</FormGroup>
|
||||
|
||||
+47
-14
@@ -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>
|
||||
@@ -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';
|
||||
|
||||
@@ -15,16 +15,18 @@ const newPassword = ref('');
|
||||
const newPasswordRepeat = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!newPassword.value) return false;
|
||||
if (newPasswordRepeat.value !== newPassword.value) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (newPasswordRepeat.value !== newPassword.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -58,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,27 +79,27 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.newPassword">
|
||||
<label>{{ $t('profile.changePassword.newPassword') }}</label>
|
||||
<PasswordInput v-model="newPassword" />
|
||||
<PasswordInput v-model="newPassword" required/>
|
||||
<div class="error-label" v-if="formError.newPassword">{{ formError.newPassword }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="newPasswordRepeat.length !== 0 && newPassword !== newPasswordRepeat">
|
||||
<label>{{ $t('profile.changePassword.newPasswordRepeat') }}</label>
|
||||
<PasswordInput v-model="newPasswordRepeat" />
|
||||
<PasswordInput v-model="newPasswordRepeat" required />
|
||||
<div class="error-label" v-if="newPasswordRepeat.length && newPassword !== newPasswordRepeat">{{ $t('profile.changePassword.errorPasswordsDontMatch') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changePassword.currentPassword') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
@@ -15,15 +15,19 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +79,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
|
||||
@@ -110,6 +110,7 @@ defineProps({
|
||||
font-weight: 400;
|
||||
font-size: 1.75em;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog } from '@cloudron/pankow';
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const status = ref(0);
|
||||
const message = ref('');
|
||||
const stackTrace = ref('');
|
||||
|
||||
async function onError(error) {
|
||||
// this is handled by the fetcher global error hook
|
||||
if (error.status === 401 || error.status >= 502 || error instanceof TypeError) return;
|
||||
|
||||
console.error(error);
|
||||
|
||||
status.value = error.status || 0;
|
||||
message.value = error.body?.message || error.message || 'unkown';
|
||||
|
||||
let stack = '';
|
||||
if (error.stack) stack = error.stack;
|
||||
else stack = (new Error()).stack;
|
||||
|
||||
if (stack.indexOf('Error') === 0) { // chrome v8
|
||||
stackTrace.value = stack.split('\n').slice(2, 7).map(l => l.slice(' at '.length).split(' ')[0] + '()').join('\n');
|
||||
} else { // firefox and safari
|
||||
stackTrace.value = stack.split('\n').slice(1, 7).map(l => l.split('@')[0] + '()').join('\n');
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
|
||||
if (!window.cloudron) window.cloudron = {};
|
||||
window.cloudron.onError = onError;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" title="Unhandled error"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div>
|
||||
<label v-if="status">Status:</label>
|
||||
<pre v-if="status">{{ status }}</pre>
|
||||
<label>Details:</label>
|
||||
<pre>{{ message }}</pre>
|
||||
<label>Trace:</label>
|
||||
<pre>
|
||||
{{ stackTrace }}
|
||||
...
|
||||
</pre>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,8 +37,17 @@ watch(password, () => {
|
||||
formError.value.password = null;
|
||||
});
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (password.value !== passwordRepeat.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
if (!form.value.reportValidity() || !isFormValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -80,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;
|
||||
@@ -107,12 +117,12 @@ onMounted(async () => {
|
||||
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
|
||||
<div>
|
||||
<div v-if="mode === MODE.SETUP">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<p style="margin-bottom: 8px">{{ $t('setupAccount.description') }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset>
|
||||
<!-- prevents autofill -->
|
||||
<input type="password" style="display: none;"/>
|
||||
@@ -145,26 +155,23 @@ onMounted(async () => {
|
||||
</form>
|
||||
|
||||
<br/>
|
||||
<Button :disabled="busy || password !== passwordRepeat" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
<Button :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.NO_USERNAME">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.noUsername.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.noUsername.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.INVALID_TOKEN">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3 class="error-label">{{ $t('setupAccount.invalidToken.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.invalidToken.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.DONE">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.success.title') }}</h3>
|
||||
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { onSwipe } from '@cloudron/pankow/gestures.js';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
defineProps({
|
||||
cloudronAvatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
},
|
||||
items: {
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
const sideBar = useTemplateRef('sideBar');
|
||||
const isVisible = ref(false);
|
||||
const isCollapsed = ref(!!window.localStorage['sideBarCollapsed']);
|
||||
|
||||
function open() {
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isVisible.value = false;
|
||||
}
|
||||
|
||||
function onToggleCollapse() {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
if (isCollapsed.value) window.localStorage['sideBarCollapsed'] = 'true';
|
||||
else window.localStorage.removeItem('sideBarCollapsed');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onSwipe(sideBar.value, (direction) => {
|
||||
if (direction === 'left') close();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar" ref="sideBar" :class="{ 'sidebar-closed': !isVisible, 'sidebar-collapsed': isCollapsed }">
|
||||
<Transition name="pankow-scale">
|
||||
<div class="sidebar-close-action" v-if="isVisible" @click="close()"><i class="fa-solid fa-xmark"></i></div>
|
||||
<div class="sidebar-open-action" v-else @click="open()"><i class="fa-solid fa-bars"></i></div>
|
||||
</Transition>
|
||||
<div class="sidebar-inner">
|
||||
<a href="#/" class="sidebar-logo" @click="close()">
|
||||
<img :src="cloudronAvatarUrl" :alt="cloudronName + ' icon'" v-tooltip.right="isCollapsed && !isMobile ? cloudronName : null"/> {{ cloudronName }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<SideBarItem v-for="item in items" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
:collapsed="isCollapsed"
|
||||
@close="close"
|
||||
/>
|
||||
</div>
|
||||
<div style="flex-grow: 1"></div>
|
||||
<div class="sidebar-collapse-action pankow-no-mobile" @click="onToggleCollapse()" v-tooltip.right="isCollapsed && !isMobile ? $t('main.sidebar.collapseAction') : null"><i class="fa-solid" :class="{ 'fa-arrow-left': !isCollapsed, 'fa-arrow-right': isCollapsed }"></i> <span v-if="!isCollapsed">{{ $t('main.sidebar.collapseAction') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
min-width: unset !important;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action {
|
||||
display: block;
|
||||
color: gray;
|
||||
border-radius: 3px;
|
||||
padding: 5px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 24px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-color-dark);
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 32px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 5px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
transition: left 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
position: fixed;
|
||||
left: -600px; /* depends on media query */
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, watch, inject } from 'vue';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
},
|
||||
route: {
|
||||
type: String,
|
||||
},
|
||||
active: {
|
||||
type: [ Boolean, Function ],
|
||||
default: false,
|
||||
},
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
visible: {
|
||||
type: [ Boolean, Function ],
|
||||
default: true,
|
||||
},
|
||||
childItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
});
|
||||
|
||||
const isMenuExpanded = ref(false);
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
watch(() => props.collapsed, () => {
|
||||
isMenuExpanded.value = false;
|
||||
isMenuOpen.value = false;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const active = props.active;
|
||||
return typeof active === 'function' ? active() : active ?? true;
|
||||
});
|
||||
|
||||
const isVisible = computed(() => {
|
||||
const visible = props.visible;
|
||||
return typeof visible === 'function' ? visible() : visible ?? true;
|
||||
});
|
||||
|
||||
function close() {
|
||||
isMenuOpen.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const subMenuElement = useTemplateRef('subMenuElement');
|
||||
const elem = useTemplateRef('elem');
|
||||
|
||||
function getViewport() {
|
||||
const win = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
w = win.innerWidth || e.clientWidth || g.clientWidth,
|
||||
h = win.innerHeight || e.clientHeight || g.clientHeight;
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: h
|
||||
};
|
||||
}
|
||||
|
||||
function getHiddenElementSize(element) {
|
||||
if (element) {
|
||||
const originalVisibility = element.style.visibility;
|
||||
const originalDisplay = element.style.display;
|
||||
|
||||
element.style.visibility = 'hidden';
|
||||
element.style.display = 'block';
|
||||
|
||||
element.clientHeight; // force reflow
|
||||
|
||||
const height = element.offsetHeight;
|
||||
const width = element.offsetWidth;
|
||||
|
||||
element.style.display = originalDisplay;
|
||||
element.style.visibility = originalVisibility;
|
||||
|
||||
return { height, width };
|
||||
}
|
||||
return { height: 0, width: 0 };
|
||||
}
|
||||
|
||||
const subMenuFlipped = ref(false);
|
||||
function toggleMenu() {
|
||||
if (props.collapsed && !isMobile.value) {
|
||||
const size = getHiddenElementSize(subMenuElement.value);
|
||||
const viewport = getViewport();
|
||||
|
||||
// rect of triggering element
|
||||
const top = elem.value.getBoundingClientRect().top;
|
||||
const bottom = elem.value.getBoundingClientRect().bottom;
|
||||
const right = elem.value.getBoundingClientRect().right;
|
||||
|
||||
// vertically flip or not
|
||||
subMenuFlipped.value = false;
|
||||
let menuTop = top;
|
||||
if (top + size.height - document.body.scrollTop > viewport.height) {
|
||||
if (top - document.body.scrollTop > viewport.height/2) {
|
||||
menuTop = bottom - size.height;
|
||||
subMenuFlipped.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
subMenuElement.value.style.left = right + 10 + 'px';
|
||||
subMenuElement.value.style.top = menuTop + 'px';
|
||||
|
||||
isMenuOpen.value = true;
|
||||
} else {
|
||||
isMenuExpanded.value = !isMenuExpanded.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdrop(event) {
|
||||
isMenuOpen.value = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible">
|
||||
<hr v-if="separator"/>
|
||||
<a v-else-if="!childItems?.length" class="sidebar-item" :class="{ active: isActive }" :href="route" @click="close()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }}</span></a>
|
||||
<div v-else-if="childItems.length" ref="elem" class="sidebar-item" :class="{ 'sidebar-item-menu-open': isMenuOpen ? '#e9ecef' : null }" @click="toggleMenu()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: isMenuExpanded }" style="margin-left: 6px;"></i></span></div>
|
||||
|
||||
<teleport to="#app">
|
||||
<div class="pankow-menu-backdrop" @click="onBackdrop($event)" @contextmenu="onBackdrop($event)" v-show="isMenuOpen"></div>
|
||||
<div v-show="isMenuOpen" ref="subMenuElement" class="sidebar-item-menu">
|
||||
<div :class="{ 'sidebar-item-menu-anchor': !subMenuFlipped }">
|
||||
<span class="sidebar-item-header">{{ label }}</span>
|
||||
</div>
|
||||
<div v-for="(item, index) in childItems" :key="item" :class="{ 'sidebar-item-menu-anchor': subMenuFlipped && index === childItems.length-1 }">
|
||||
<hr v-if="item.separator"/>
|
||||
<a v-else class="sidebar-item" :href="item.route" @click="close()"><i :class="item.icon"></i> {{ item.label }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="isMenuExpanded">
|
||||
<SideBarItem v-for="item in childItems" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
@close="close()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar-item-menu {
|
||||
position: absolute;
|
||||
z-index: 3002; /* backdrop is at 3001 -> see pankow */
|
||||
background-color: var(--navbar-background);
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
border-bottom-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item-menu-open {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-menu-anchor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 37px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item-header,
|
||||
.sidebar-item:hover,
|
||||
.sidebar-item-menu-open,
|
||||
.sidebar-item-menu-anchor::before {
|
||||
background-color: #282d38;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-label-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar-item-label-collapsed {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -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';
|
||||
|
||||
@@ -4,10 +4,11 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, Menu, 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';
|
||||
import Section from '../components/Section.vue';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
@@ -22,16 +23,11 @@ const tasksModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
const columns = {
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
@@ -46,19 +42,25 @@ const columns = {
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
sort: false,
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
sort: true
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-circle-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, backup),
|
||||
@@ -70,9 +72,13 @@ function onActionMenu(backup, event) {
|
||||
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),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
@@ -157,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);
|
||||
@@ -167,6 +185,27 @@ 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() {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
}
|
||||
|
||||
async function onDownloadConfig(backup) {
|
||||
@@ -206,25 +245,23 @@ async function onEditSubmit() {
|
||||
|
||||
const [error] = await backupsModel.update(editBackupId.value, editBackupLabel.value, editBackupPersist.value ? -1 : 0);
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
editBackupBusy.value = false;
|
||||
editBackupError.value = error.body?.message || JSON.stringify(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshBackups();
|
||||
editBackupBusy.value = false;
|
||||
editDialog.value.close();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
|
||||
busy.value = false;
|
||||
@@ -232,14 +269,16 @@ onMounted(async () => {
|
||||
await refreshTasks();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
defineExpose({ refresh });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section :title="$t('backups.listing.title')">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
|
||||
<BackupInfoDialog ref="infoDialog" />
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
@@ -251,7 +290,7 @@ defineExpose({ refresh });
|
||||
:confirm-busy="editBackupBusy"
|
||||
@confirm="onEditSubmit()"
|
||||
>
|
||||
<p class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</p>
|
||||
<div class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</div>
|
||||
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
@@ -260,38 +299,50 @@ defineExpose({ refresh });
|
||||
<TextInput id="backupLabelInput" v-model="editBackupLabel" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" />
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
<!-- <sup><a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{ 'backups.backupEdit.preserved.tooltip' | tr: { appsLength: editBackup.backup.contents.length} }}"><i class="fa fa-question-circle"></i></a></sup> -->
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<div v-html="t('backups.listing.description', { restoreLink: 'https://docs.cloudron.io/backups/#restore-cloudron', migrateLink: 'https://docs.cloudron.io/backups/#move-cloudron-to-another-server' })"></div>
|
||||
|
||||
<br/>
|
||||
|
||||
<template #header-buttons>
|
||||
<Button tool secondary :menu="taskLogsMenu" :disabled="!taskLogsMenu.length">{{ $t('main.action.logs') }}</Button>
|
||||
</template>
|
||||
|
||||
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
|
||||
<template #preserveSecs="backup">
|
||||
<i class="fas fa-archive" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i>
|
||||
<template #creationTime="{ item:backup }">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #creationTime="backup">{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b></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">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
<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>
|
||||
</Section>
|
||||
|
||||
@@ -15,32 +15,13 @@ import AppsModel from '../models/AppsModel.js';
|
||||
import UpdaterModel from '../models/UpdaterModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, prettySchedule, parseSchedule } from '../utils.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const updaterModel = UpdaterModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
function prettyAutoUpdateSchedule(pattern) {
|
||||
if (!pattern) return '';
|
||||
const tmp = pattern.split(' ');
|
||||
|
||||
if (tmp.length === 1) return tmp[0];
|
||||
|
||||
const hours = tmp[2].split(',');
|
||||
const days = tmp[5].split(',');
|
||||
const prettyDay = (days.length === 7 || days[0] === '*') ? 'Every day' : days.map((day) => { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(', ');
|
||||
|
||||
try {
|
||||
const prettyHour = hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.value - b.value).map(h => h.name).join(', ');
|
||||
return prettyDay + ' at ' + prettyHour;
|
||||
} catch (error) {
|
||||
console.error('Unable to build pattern.', error);
|
||||
return 'Custom pattern';
|
||||
}
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const updateDialog = useTemplateRef('updateDialog');
|
||||
|
||||
@@ -49,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({});
|
||||
@@ -74,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() {
|
||||
@@ -106,27 +87,22 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
configureType.value = configurePattern.value === 'never' ? 'never' : 'pattern';
|
||||
configurePolicy.value = currentPolicy.value || 'never';
|
||||
|
||||
const tmp = currentPattern.value.split(' ');
|
||||
const hours = tmp[2] ? tmp[2].split(',') : [];
|
||||
const days = tmp[5] ? tmp[5].split(',') : [];
|
||||
|
||||
if (days[0] === '*') configureDays.value = cronDays.map(day => { return day.id; });
|
||||
else configureDays.value = days.map(day => { return parseInt(day, 10); });
|
||||
|
||||
try {
|
||||
configureHours.value = hours.map(hour => { return parseInt(hour, 10); });
|
||||
} catch (error) {
|
||||
console.error('Error parsing hour', error);
|
||||
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(',');
|
||||
@@ -135,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();
|
||||
@@ -264,7 +240,7 @@ onMounted(async () => {
|
||||
ubuntuVersion.value = result.ubuntuVersion;
|
||||
|
||||
await refreshPendingUpdateInfo();
|
||||
await refreshAutoupdatePattern();
|
||||
await refreshAutoupdateConfig();
|
||||
await refreshTasks();
|
||||
|
||||
ready.value = true;
|
||||
@@ -313,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>
|
||||
@@ -346,9 +332,10 @@ onMounted(async () => {
|
||||
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(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>
|
||||
@@ -372,7 +359,7 @@ onMounted(async () => {
|
||||
<div class="button-bar" v-if="ready">
|
||||
<Button :disabled="checkingBusy" :loading="checkingBusy" v-if="!updateBusy" @click="onCheck()">{{ $t('settings.updates.checkForUpdatesAction') }}</Button>
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<Button :danger="(pendingUpdate && pendingUpdate.unstable) ? true : undefined" :success="(pendingUpdate && !pendingUpdate.unstable) ? true : undefined" v-show="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
<Button :danger="pendingUpdate?.unstable" :success="!pendingUpdate?.unstable" v-if="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -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('');
|
||||
@@ -139,9 +140,9 @@ function onSchedulerMenu(event) {
|
||||
async function onRestartApp() {
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no'),
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -32,7 +32,6 @@ const roles = ref([]);
|
||||
const profile = ref({});
|
||||
const busy = ref(false);
|
||||
const profileLocked = ref(false);
|
||||
const external2FA = ref(false);
|
||||
const formError = ref({});
|
||||
const displayName = ref('');
|
||||
const email = ref('');
|
||||
@@ -40,26 +39,12 @@ const fallbackEmail = ref('');
|
||||
const avatarUrl = ref('');
|
||||
const username = ref('');
|
||||
const role = ref('');
|
||||
const groups = ref([]);
|
||||
const localGroups = ref([]);
|
||||
const localGroupIds = ref([]);
|
||||
const allGroups = ref([]);
|
||||
const allLocalGroups = ref([]);
|
||||
const active = ref(true);
|
||||
const sendInvite = ref(false);
|
||||
const isSelf = ref(false);
|
||||
const reset2FABusy = ref(false);
|
||||
|
||||
async function onReset2FA() {
|
||||
if (!user.value) return;
|
||||
|
||||
reset2FABusy.value = true;
|
||||
|
||||
const [error] = await usersModel.disableTwoFactorAuthentication(user.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
user.value.twoFactorAuthenticationEnabled = false;
|
||||
reset2FABusy.value = false;
|
||||
}
|
||||
|
||||
let avatarFile = 'src';
|
||||
function onAvatarChanged(file) {
|
||||
@@ -68,7 +53,7 @@ function onAvatarChanged(file) {
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value && form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -87,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;
|
||||
|
||||
@@ -146,7 +131,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroups.value);
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroupIds.value);
|
||||
if (groupError) {
|
||||
formError.value.generic = groupError.body ? groupError.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
@@ -203,8 +188,7 @@ defineExpose({
|
||||
result.forEach(g => g.label = g.name);
|
||||
allGroups.value = result;
|
||||
allLocalGroups.value = result.filter(g => !g.source);
|
||||
groups.value = u ? u.groupIds : [];
|
||||
localGroups.value = (u ? u.groupIds.filter(g => !g.source) : []);
|
||||
localGroupIds.value = u ? u.groupIds.filter(gid => allLocalGroups.value.find(g => g.id === gid)) : [];
|
||||
|
||||
[error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
@@ -222,10 +206,11 @@ defineExpose({
|
||||
[error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
profileLocked.value = result.profileLocked;
|
||||
external2FA.value = result.external2FA;
|
||||
|
||||
imagePicker.value.reset();
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,15 +225,11 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
alternate-style="secondary"
|
||||
:alternate-label="(user && user.twoFactorAuthenticationEnabled && !(user.source && external2FA)) ? $t('users.passwordResetDialog.reset2FAAction') : null"
|
||||
:alternate-busy="reset2FABusy"
|
||||
@alternate="onReset2FA()"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid" />
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
<div style="display: flex; justify-content: center;">
|
||||
<div style="width: 80px;">
|
||||
@@ -259,23 +240,23 @@ defineExpose({
|
||||
<div class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</div>
|
||||
<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 until one is set -->
|
||||
<FormGroup :has-error="formError.username">
|
||||
<!-- if profile edit is locked a username has to be set here . username is editable if none is set -->
|
||||
<FormGroup :has-error="!!formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="profileLocked ? true : null" :readonly="user?.username ? true : undefined" />
|
||||
<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>
|
||||
<EmailInput id="emailInput" v-model="email" :readonly="(user && user.source) ? true : undefined" required />
|
||||
<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>
|
||||
|
||||
<FormGroup style="flex-grow: 1">
|
||||
<label for="displayNameInput">{{ $t('users.user.fullName') }}</label>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :readonly="(user && user.source) ? true : undefined"/>
|
||||
<TextInput id="displayNameInput" v-model="displayName" :readonly="user?.source ? true : false"/>
|
||||
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
|
||||
</FormGroup>
|
||||
|
||||
@@ -295,7 +276,7 @@ defineExpose({
|
||||
<FormGroup>
|
||||
<label for="groupsInput">{{ $t('users.user.groups') }}</label>
|
||||
<div v-if="allGroups.length === 0">{{ $t('users.user.noGroups') }}</div>
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroupIds" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
</FormGroup>
|
||||
|
||||
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
|
||||
|
||||
@@ -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);
|
||||
@@ -92,7 +99,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('domains.domainWellKnown.title', { domain })"
|
||||
:title="$t('domains.wellknown.title')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
@@ -100,7 +107,9 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p v-html="$t('domains.domainDialog.wellKnownDescription', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></p>
|
||||
<div v-html="$t('domains.wellknown.context', { domain })"></div>
|
||||
<br/>
|
||||
<div v-html="$t('domains.wellknown.description', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
@@ -108,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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -4,8 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Icon, Button, Switch, Checkbox, FormGroup, TextInput, TableView, Menu, 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,92 +14,97 @@ 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' ]);
|
||||
|
||||
const columns = ref({
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
sort(a, b) {
|
||||
return b.name <= a.name ? 1 : -1;
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
sort: true,
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('app.backups.backups.time'),
|
||||
sort: true,
|
||||
integrity: {
|
||||
label: 'Integrity',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
align: 'center',
|
||||
},
|
||||
actions: {
|
||||
label: '',
|
||||
sort: false,
|
||||
width: '100px',
|
||||
}
|
||||
});
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
const accessLevel = props.app.accessLevel;
|
||||
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, 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',
|
||||
label: t('app.backups.backups.restoreTooltip'),
|
||||
disabled: !!props.app.taskId || props.app.runState === 'stopped',
|
||||
action: onRestore.bind(null, backup),
|
||||
// }, {
|
||||
// separator: true,
|
||||
// }, {
|
||||
// icon: 'fa-solid fa-key',
|
||||
// label: t('app.backups.backups.checkIntegrity'),
|
||||
// visible: props.app.accessLevel === 'admin',
|
||||
// action: onCheckIntegrity.bind(null, backup),
|
||||
quickAction: true
|
||||
}, {
|
||||
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),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
@@ -133,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;
|
||||
@@ -150,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] || {};
|
||||
@@ -160,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}`); }
|
||||
};
|
||||
});
|
||||
|
||||
@@ -180,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();
|
||||
@@ -199,6 +204,7 @@ function onEdit(backup) {
|
||||
editLabel.value = backup.label || '';
|
||||
editError.value = '';
|
||||
editDialog.value.open();
|
||||
setTimeout(() => document.getElementById('labelInput').focus(), 500);
|
||||
}
|
||||
|
||||
async function onEditSubmit() {
|
||||
@@ -235,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;
|
||||
@@ -263,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 () => {
|
||||
@@ -293,11 +323,14 @@ onMounted(async () => {
|
||||
busy.value = false;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (integrityPollTimer) clearTimeout(integrityPollTimer);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<AppRestoreDialog ref="cloneDialog"/>
|
||||
<AppImportDialog ref="importDialog"/>
|
||||
|
||||
@@ -315,14 +348,14 @@ onMounted(async () => {
|
||||
<div>
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBusy">
|
||||
<p class="has-error" v-show="editError">{{ editError }}</p>
|
||||
<div class="has-error" v-show="editError">{{ editError }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="labelInput">{{ $t('backups.backupEdit.label') }}</label>
|
||||
<TextInput v-model="editLabel" id="labelInput" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')"/>
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
@@ -378,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>
|
||||
@@ -391,23 +424,29 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
|
||||
<template #preserveSecs="backup">
|
||||
<Icon icon="fa-solid fa-archive" v-show="backup.preserveSecs === -1" />
|
||||
</template>
|
||||
<template #creationTime="backup">
|
||||
{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b>
|
||||
</template>
|
||||
<template #site="backup">
|
||||
{{ backup.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>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
<template #creationTime="{ item }">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(item.creationTime) }}</span>
|
||||
<span v-if="item.label"> <b>{{ item.label }}</b></span>
|
||||
<span> <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="{ item }">
|
||||
{{ item.site.name }}
|
||||
</template>
|
||||
<template #size="{ item }">
|
||||
<span v-if="item.stats?.upload">{{ prettyFileSize(item.stats.upload.size) }} - {{ item.stats.upload.fileCount }} file(s)</span>
|
||||
</template>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -41,15 +41,17 @@ const crontabDefault = `# +------------------------ minute (0 - 59)
|
||||
|
||||
const busy = ref(false);
|
||||
const crontab = ref('');
|
||||
const submitError = ref({});
|
||||
|
||||
async function onSubmit() {
|
||||
if (crontab.value === crontabDefault && !props.app.crontab) return;
|
||||
if (crontab.value === props.app.crontab) return;
|
||||
|
||||
submitError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
const [error] = await appsModel.configure(props.app.id, 'crontab', { crontab: crontab.value });
|
||||
if (error) return console.error(error);
|
||||
if (error) submitError.value.generic = error.body?.message || JSON.stringify(error);
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -73,6 +75,7 @@ onMounted(() => {
|
||||
</label>
|
||||
<div description>{{ $t('app.cron.description') }}</div>
|
||||
<textarea id="crontabInput" style="width: 100%; white-space: pre-wrap; font-family: monospace;" v-model="crontab" rows="10"></textarea>
|
||||
<div class="error-label" v-show="submitError.generic">{{ submitError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user