Compare commits
1183 Commits
v9.0.0
..
authserver
| 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 | |||
| 11c5a3f050 | |||
| 10645b1b94 | |||
| e106dcd76a | |||
| cb30a57a59 | |||
| 98da4c0011 | |||
| fc0c316ef2 | |||
| eaf363635e | |||
| b91aa0668f | |||
| 53c2f5885a | |||
| 5717f77e00 | |||
| 3f8dfdd938 | |||
| 9e1fbedc4d | |||
| f9eb588d4c | |||
| 181ee43107 | |||
| cc30bc1897 | |||
| 1232b30e29 | |||
| 03aae46880 | |||
| 25ce947df5 | |||
| b8f486d8e4 | |||
| 6305ff7410 | |||
| b2941894cd | |||
| 83056519ec | |||
| 3cdfbbac56 | |||
| f61e85c2d6 | |||
| 217ebf8c33 | |||
| b32114f2f2 | |||
| 6209cdbe0e | |||
| afde81ef3e | |||
| fbbd71e7f2 | |||
| 54cf168b4d | |||
| c25b14976c | |||
| 39c68075fb | |||
| ce15958a9a | |||
| 8d06defbcb | |||
| 0d807a37d6 | |||
| 9a0a2d84da | |||
| 29e2be47d0 | |||
| b2e1f66dbb | |||
| bfe9ee457d | |||
| a034b70449 | |||
| 4226654772 | |||
| 4ea8ab08a3 | |||
| 702fc120af | |||
| 9453084481 | |||
| c6dbbc4135 | |||
| ddc53bcb6f | |||
| e50509ac45 | |||
| 2ddba469b2 | |||
| 4e1b2ccbaa | |||
| e0b8a2400a | |||
| 151ba569a7 | |||
| 2cb755fe44 | |||
| eeef49fd19 | |||
| 6b2626120c | |||
| e77ab26516 | |||
| dbaf6c6ce2 | |||
| 5e295f9f1e | |||
| 8d3b655517 | |||
| 64cefd52c8 | |||
| edb92ed0a5 | |||
| a8513cc0fa | |||
| 20d4ce6632 | |||
| d8c3ce30ca | |||
| d894de0784 | |||
| 572bd19df6 | |||
| 4fd399eae9 | |||
| f7f55710d1 | |||
| 18815b97ce | |||
| c4fce32a6a | |||
| 9ed5f43ea1 | |||
| 232bce0a2d | |||
| 27f975f3c5 | |||
| 5b834b4396 | |||
| 52b46e2b3e | |||
| 044fb72da9 | |||
| 0cf911bcdd | |||
| 829512dd13 | |||
| fa886c71b8 | |||
| 21191bdc50 | |||
| 1bf2fe16a2 | |||
| c35543af92 | |||
| 9bb71bd066 | |||
| f24e4f291d | |||
| 32ab9a9d32 | |||
| 8b520dec48 | |||
| 70c539ac4d | |||
| 610651066a | |||
| aaa750dbbc | |||
| a518ee83cc | |||
| de84b5113c | |||
| 2ea7847d4f | |||
| 0650fca1cf | |||
| 1b5bd0d379 | |||
| 5b6f796606 | |||
| 9d6a755486 | |||
| 9470654394 | |||
| 28feadd6c5 | |||
| af3ed04b7f | |||
| 2da99673cd | |||
| 476adcb029 | |||
| b2c8f87276 | |||
| bd4e132709 | |||
| fa8fcf8761 | |||
| 8e92b53d9f | |||
| 6f90bd3db0 | |||
| a261d8b754 | |||
| 9643b7ed1b | |||
| ec191d51bc | |||
| a5452e4b15 | |||
| 8522802f85 | |||
| 6f2e3afe07 | |||
| 70dfb41d95 | |||
| 34f04828c5 | |||
| a78799973d | |||
| 1797148951 | |||
| 67caa89591 | |||
| e3a88e9f5b | |||
| e9910c9b95 | |||
| 45e058bdc1 | |||
| 9af5404921 | |||
| 5c4ca1b699 | |||
| b6827736db | |||
| aada3f3979 | |||
| dc07078fd4 | |||
| ae8278bdb3 | |||
| 286de8cdcb | |||
| ca11d5af94 | |||
| fb04f78112 | |||
| 75fa2dfd67 | |||
| 137267e604 | |||
| 642487f4c5 | |||
| 783ad9ecda | |||
| 0213a368b9 | |||
| f1e7594b79 | |||
| 02fd52e366 | |||
| 2d5e0a51bd | |||
| 1cd82dcd4c | |||
| 5ba30d0236 | |||
| c0ea5c31eb | |||
| adee5fa25f | |||
| f9af84fd85 | |||
| 41cb381a2e | |||
| 50ca07bfb8 | |||
| 07732310c1 | |||
| 854661e2d4 | |||
| 8cac83ed98 | |||
| 5ee8e9da80 | |||
| f5c81f5882 | |||
| a415b70adf | |||
| 800a7e26e9 | |||
| 1bc9dc30f6 | |||
| 7d538ee1b8 | |||
| ac5f4cca19 | |||
| 54a5d5b9aa | |||
| 5c4ec5afc0 | |||
| 5bd6001f95 | |||
| 0fb8914b67 | |||
| 1f6ac49686 | |||
| 42887fb1d9 | |||
| f14a7808cb | |||
| a781a46f13 | |||
| 6941a12314 | |||
| f0e70a97bc | |||
| c59e3ef4ae | |||
| 2bfdc7c1ac | |||
| d831e7d765 | |||
| fe8ef5b922 | |||
| 2c150eee33 | |||
| a4d6bafe1a | |||
| 78017b8adb | |||
| ea822f66ca | |||
| a55adf12db | |||
| 84c016490c | |||
| bb7056d614 | |||
| 462b490d05 | |||
| 084050bb2f | |||
| 8d2ea7e736 | |||
| fe8d5b0d3e | |||
| de724319aa | |||
| ac91b417c3 | |||
| 229863d7ff | |||
| 8dcb3f2f85 | |||
| 15c8f84960 | |||
| f37dd03e4b | |||
| 82c97f7e1c | |||
| 91078f7a7e | |||
| d2775956e0 | |||
| 00b52fa3af | |||
| 1ac0ed3c18 | |||
| 6ec8246b46 | |||
| f5978a524d | |||
| 72030ee8fc | |||
| d6a4dd6965 | |||
| 8aa5dc85af | |||
| 5c7f99c0ee | |||
| 847cb91759 | |||
| 9e92d08261 | |||
| bf8e03aa0c | |||
| fcd05f3bb4 | |||
| a14dfc171d | |||
| b8b445eb24 | |||
| fbf4a53a1b | |||
| 0c7e810bd3 | |||
| 0502779a29 | |||
| 576d9ca894 | |||
| d8771509cd | |||
| b139749198 | |||
| bdcb5c502c | |||
| dc72df1dbd | |||
| 8be834d0c8 | |||
| c995454f69 | |||
| 854e0ebe3f | |||
| f01d2631dd | |||
| 60f8cdf3b4 | |||
| 8e5bf14623 | |||
| b063ebd6d7 | |||
| eb7d7a2d1b | |||
| f9ee088592 | |||
| 1f32d4b4dd | |||
| d3b4c2f394 | |||
| 41c00eda74 | |||
| 155af33b0c | |||
| b289146aeb | |||
| d2e32a4fd0 | |||
| 6631c95166 | |||
| 7adabcc203 | |||
| de35a935a6 | |||
| d3d668d930 | |||
| 1f60c6dd21 | |||
| 1431700642 | |||
| 12a1de56fd | |||
| 4267208f28 | |||
| 25d6ec157f | |||
| b2fc7df06d | |||
| c875371db9 | |||
| dd7818b960 | |||
| 0dd6446f3e | |||
| 579dc4959b | |||
| 27c3170c80 | |||
| 7b275008b5 | |||
| 2cfea229fd | |||
| e349193745 | |||
| 45d68c4da8 | |||
| 36d443fa36 | |||
| 8549663ed0 | |||
| 73b2feae59 | |||
| 0871b416a7 | |||
| 767bfba670 | |||
| 019cbeb617 | |||
| a7d8a8fd1f | |||
| f7e073c857 | |||
| 2fdb3668e2 | |||
| f1aee1d9a4 | |||
| 057f75ca5f | |||
| 314654f319 | |||
| 961959d361 | |||
| d603ea50e2 | |||
| 3b7bc6beba | |||
| e437168e87 | |||
| cf7d735c56 | |||
| b2a41cc4d5 | |||
| 5bc3cb6353 | |||
| e6024ac85b | |||
| 4f6fac2336 | |||
| 12feb68bf0 | |||
| d5d325bf4e | |||
| 47ae1443d1 | |||
| 63b51d2fca | |||
| 3547be3401 | |||
| a038028c3b | |||
| 40cc12e568 | |||
| 4a1a915add | |||
| 3096b67b76 | |||
| 3acceca32f | |||
| e8e05159d8 | |||
| ae0c7390f2 | |||
| 11f2acd702 | |||
| 1759e257c4 | |||
| 3e86c59607 | |||
| dfe5b6b1f2 | |||
| b0026eafb5 | |||
| 67519fb203 | |||
| 7f4a9d6016 | |||
| 317f6e77d4 | |||
| 5fcfd32f6c | |||
| 0fd056dff9 | |||
| 10cf8f1d0e | |||
| 67d95cda76 | |||
| 3ac66f9dd4 | |||
| 43e426ab9f | |||
| a5224258c3 | |||
| ab3a2911c3 | |||
| 1cdcab6047 | |||
| 2ad8ed5550 | |||
| 9226baa63c | |||
| b790d085bb | |||
| 933d5c9139 | |||
| 2c5fd7effa | |||
| f5a5bebae6 | |||
| f2316ec84e | |||
| 84165e5342 | |||
| f65dab114e | |||
| 4ad7504be6 | |||
| e9318d7f11 | |||
| 01d7d41c17 | |||
| f1fb5f2530 | |||
| ea28c10a39 | |||
| 6c84681f35 | |||
| edb3aea880 | |||
| f898925bc0 | |||
| f3be7aa763 | |||
| 71c52a87ec | |||
| 8c786f38ab | |||
| 6bc5826c86 | |||
| 90e36c4552 | |||
| f83bd2e3b7 | |||
| 367eb1b1e1 | |||
| 91e21d69db | |||
| 5bcfea161f | |||
| a9e0ee81ce | |||
| c37c513067 | |||
| c0972b3e14 | |||
| 1b91ae1ab3 | |||
| 4a462597fd | |||
| 505c71855e | |||
| 2ba0d716d1 | |||
| 35ab03c392 | |||
| ddb7551b92 | |||
| fdcc5d68a2 | |||
| ac7c32ad4c | |||
| e93898d2ec | |||
| 22fe27da9c | |||
| e31164140c | |||
| 7d53eeb7f5 | |||
| d3731ff339 | |||
| 02ba2fe59b | |||
| 55091cfe8d | |||
| f6e14a5420 | |||
| b38371400c | |||
| 0458fee326 | |||
| 15256ebbdf | |||
| 773435fb7f | |||
| 1a674a30ac | |||
| 491617e41c | |||
| 24fc480f7b | |||
| 677f11ba1a | |||
| aa7e307205 | |||
| 6ac914904e | |||
| 68dd1fbedb | |||
| 6869380500 | |||
| 3a0febe760 | |||
| 46d195557b | |||
| 548418ecfe | |||
| 28c0d63f89 | |||
| b3eb6ccde3 | |||
| 0e09df797b | |||
| 7fae0751b9 | |||
| e6cdd1ed7a | |||
| 9d8c079d37 | |||
| 9bb58b1649 | |||
| d732adf34b | |||
| 8718e7efd2 | |||
| b21d29098b | |||
| d010330b58 | |||
| 4562024e72 | |||
| 34043d5c97 | |||
| a468b6ff39 | |||
| 534aa7423e | |||
| be416fd335 | |||
| 43962c4a5a | |||
| 1b33b2c48c | |||
| efa4c09306 | |||
| 181b9cdee6 | |||
| a1c3cd272b | |||
| c67b4a4e51 | |||
| 72c97ba224 | |||
| ca96e40397 | |||
| 758daee0c9 | |||
| 37b6a2568d | |||
| 4ee2fd8b14 | |||
| 7706be3e2f | |||
| 16c2316183 | |||
| ce22100b02 | |||
| d2cc38c8ec | |||
| c0a31ccb55 | |||
| 8ddc9e3138 | |||
| 7a24c23bdd | |||
| 32f4b72d68 | |||
| 282df5c2e6 | |||
| 6713ba3798 | |||
| 355edda058 | |||
| b1141f8cbb | |||
| eaad527e32 | |||
| 810922de5e | |||
| da3414e3bc | |||
| 99a0c78fe4 | |||
| 444d3eeb7c | |||
| a3c00c5f75 | |||
| dfb465ef77 | |||
| b20107ad2a | |||
| 6977556984 | |||
| 676f25962d | |||
| 02fe971f0b | |||
| 0b310f849a | |||
| c9c00d2b9f | |||
| 910087e37a | |||
| 9d99794242 | |||
| 3218d7c64d | |||
| 3364e5c876 | |||
| a6355e1945 | |||
| a3a8e67ce2 | |||
| ac388e1daf |
@@ -4,3 +4,5 @@ installer/src/certs/server.key
|
||||
# vim swap files
|
||||
*.swp
|
||||
|
||||
.cursor
|
||||
|
||||
|
||||
@@ -2966,3 +2966,256 @@
|
||||
* sqlite: fix issue where dump was also logged when backing up
|
||||
* backups: remove noop backend
|
||||
|
||||
[9.0.1]
|
||||
* redis: update to 8.2.2
|
||||
* Split the ubuntu version and cloudron version
|
||||
* Restructure sidebar menu items
|
||||
* eventlog: fix display of backup (sites) events
|
||||
* app archive: fix download config
|
||||
* graphs: fix performance issue when selecting apps
|
||||
* Support overwrite DNS in app install dialog
|
||||
* encryption: do not allow password and hint to be the same
|
||||
* Add better grouping to mailbox owner select
|
||||
* eventlog: display task log link when available
|
||||
* add ephemeral port warning
|
||||
* rsync: fix integrity computation
|
||||
|
||||
[9.0.2]
|
||||
* backupsite: only owner can add a site
|
||||
* remove max-height from the users view and groups view tables
|
||||
* backups: fix listing when stats is null
|
||||
* graphs: fix detection of rootfs block device
|
||||
* sidebar: ldap/openid/directory should not be visible to non-admins
|
||||
* sidebar: email domains, eventlog, settings is only for admins
|
||||
* reload dashboard on Cloudron version change
|
||||
* Always start with a fresh domains list for the apps filter
|
||||
* sysinfo: fallback to product family if product vendor is empty
|
||||
* archive: display the site name of latest backup
|
||||
* graphs: fix flickering of disk graph item
|
||||
* graphs: fix issue with live graph time calculation
|
||||
|
||||
[9.0.3]
|
||||
* Fix submit state for login form
|
||||
* Avoid flickering of SystemUpdate view when update is busy
|
||||
* backuptask: fix crash when accessing stats of old backups
|
||||
* backup sites: fix listing when status call errors
|
||||
* backups: display mail backup stats
|
||||
* Add missing autocomplete attributes on forms
|
||||
* Refresh backup site status and task in the background
|
||||
* Hide non-owner actions for backup sites
|
||||
* Move app start/stop back to the main toolbar
|
||||
* Fix styling in public page
|
||||
* network: fix ip caching bug
|
||||
* Change default footer to not have the forum link
|
||||
* Fix troubleshooting tool
|
||||
* Give domains list a larger max-height
|
||||
* Make app error compatible with previous releases
|
||||
|
||||
[9.0.4]
|
||||
* filemanager: fix missing translations
|
||||
* display backup duration
|
||||
* add hetznercloud DNS provider
|
||||
|
||||
[9.0.5]
|
||||
* access control/operators: remove deleted users and groups
|
||||
* backupcleaner: fix scoping of cleanup by site id
|
||||
* Use normal buttons for app start/stop
|
||||
* site schedule: Fix hourly display
|
||||
|
||||
[9.0.6]
|
||||
* Autofocus search in appstore view
|
||||
* All settings in sidebar should be same icon
|
||||
* Make backup content list a TableView so we can sort it by size and fileCount
|
||||
* Fix filemanager for custom apps
|
||||
* Sort apps in the grid by label
|
||||
* Filter dropdowns are searchable with more than 10 entries
|
||||
* Show app icons in the grid in grayscale if app is stopped
|
||||
* Support wildcard domain aliases in app location
|
||||
|
||||
[9.0.7]
|
||||
* externalldap: only set group members if they changed
|
||||
* Fix issue where backups remote paths were incorrectly migrated
|
||||
|
||||
[9.0.8]
|
||||
* Add explicit option to disable automatic backups
|
||||
* backups: show same filesystem warning
|
||||
* Fix tgz app backup download
|
||||
* Fix mailbox usage and quota sorting
|
||||
* Give sshfs identity files unique filenames across mounts
|
||||
* Do not share relay provider setting with view and form
|
||||
* cloudflare: ensure defaultProxyStatus in older configs
|
||||
* filter: fix domain search to include redirect/alias/secondary domains
|
||||
* Use full URLs for page preview icons and favicon
|
||||
* email: fix masquerade toggle
|
||||
|
||||
[9.0.9]
|
||||
* minio: fix issue with accepting selfsigned certs
|
||||
* applink: fix button text in edit mode
|
||||
* password reset: show error message if any
|
||||
* sshfs: use a temporary identity file for remote ssh copy
|
||||
* access control: always show the user management section
|
||||
* update: show the last update error, if any
|
||||
|
||||
[9.0.10]
|
||||
* Only enable LdapServer input fields if feature is enabled
|
||||
* Require display name to not be empty when changed from the profile view
|
||||
* 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.' }));
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
|
||||
## Translations
|
||||
|
||||
This documents the convention used for the text in the UI.
|
||||
|
||||
### Tale of Two Cases
|
||||
|
||||
**Title Case**
|
||||
|
||||
All words are capitalized. In title case, articles (a/an/the), conjunctions (and/but/or/...)
|
||||
and prepositions (on/at/...) inside a phrase are not capitalized. Everything else is capitalized
|
||||
- noun, pronoun, verb, adverb.
|
||||
|
||||
Examples:
|
||||
|
||||
* "Sign In to Your Account"
|
||||
* "Terms and Conditions"
|
||||
* "Getting Started with GraphQL"
|
||||
* "Between You and Me"
|
||||
|
||||
**Sentence Case**
|
||||
|
||||
Only first word is capitalized.
|
||||
|
||||
### UI Conventions
|
||||
|
||||
Keeping as much as possible in Sentence Case helps in sharing the same strings.
|
||||
|
||||
| Element | Recommended Style | Example |
|
||||
| -------------- | ---------------------- | -------------------------------- |
|
||||
| Headings | Title Case | Manage Account |
|
||||
| Sub heading | Title Case | Create Admin Account |
|
||||
| Section/Card | Title Case | System Information |
|
||||
| Form Labels | Sentence case | Email address |
|
||||
| Form Groups | Sentence case | Volume mounts, Data directory |
|
||||
| Table headings | Sentence case | Memory limit |
|
||||
| Info sections | Sentence case | Cloudron version |
|
||||
| Buttons | Sentence case | Save changes |
|
||||
| Radio Buttons | Sentence case | Option one / Option two |
|
||||
| Checkbox | Sentence case | Use CIFS encryption |
|
||||
| Menu action | Sentence case | Select all |
|
||||
| Switches | Sentence case | Allow users to edit email |
|
||||
| Descriptions | Sentence case | Enter your password to continue. |
|
||||
| Tooltips | Sentence case | Click to edit. |
|
||||
| Error Messages | Sentence case | Password is too short |
|
||||
| Notifications | Sentence case | Settings saved successfully. |
|
||||
| Legend (graph) | Sentence case | Docker volume, Box data. |
|
||||
| Placeholders | Sentence case | Comma separated IPs or subnets |
|
||||
|
||||
Hints in brackets are small case. Like "(comma separated)".
|
||||
|
||||
### Full Stops
|
||||
|
||||
Sentence fragments like form hints and tooltips (which are always visible) do not need a full stop.
|
||||
All other full sentences do.
|
||||
|
||||
Description has a full stop unless it's a hint/phrase.
|
||||
|
||||
instructional heading in dialogs (like the object being configured) should not have a full stop.
|
||||
|
||||
Switch UI description does not have a fullstop.
|
||||
|
||||
Setting item description does not need a fullstop (usually).
|
||||
|
||||
Checkbox labels do not have a full stop at the end.
|
||||
|
||||
No full stop → short labels, commands, headings, or action text (“Configure Service {{serviceName}}”).
|
||||
|
||||
Full stop → descriptive text or sentences explaining a setting (“The IPv4 address used for DNS A records.”).
|
||||
|
||||
### Dialog Buttons
|
||||
|
||||
'Add' for addition
|
||||
'Cancel' to cancel
|
||||
'Save' for edit/update
|
||||
'Remove' for non-destructive/less destructive things (app password remove)
|
||||
'Delete' for destructive (user delete)
|
||||
|
||||
'Close' - Only for dialogs with the only button
|
||||
|
||||
### Dialog Text
|
||||
|
||||
When asking for confirmation simply ask 'Remove app password "xxx"' . Don't use "really"
|
||||
or other emotional terms. Quote the password/domain name.
|
||||
|
||||
In general, we put just "Delete User" in Title and provide the username in the context.
|
||||
|
||||
Title = action (what you’re doing)
|
||||
Description = context (to whom it applies)
|
||||
|
||||
### Description Text
|
||||
|
||||
| Context | Verb form | Example |
|
||||
| --------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
|
||||
| **Action / Button / Instruction** | **Imperative** → “Add” | Button: **Add**, Tooltip: “Add a new link” |
|
||||
| **Section / View description** | **Imperative** → “Add” | Description: **Adds shortcuts to external services on the dashboard.** |
|
||||
|
||||
We use plural when possible. "Admins can ..." , "Operators can ..."
|
||||
|
||||
+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
+1699
-2621
File diff suppressed because it is too large
Load Diff
+18
-17
@@ -7,26 +7,27 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.3.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.8",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.0.1",
|
||||
"@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.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.36.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"marked": "^16.3.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.1.7",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
"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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+635
-1036
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,22 +3,10 @@
|
||||
"rebootDialog": {
|
||||
"title": "本当にサーバーを再起動しますか?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "バックアップIDをクリックしてコピー",
|
||||
"clickToCopy": "クリックしてコピー",
|
||||
"copied": "クリップボードにコピーしました"
|
||||
},
|
||||
"action": {
|
||||
"logs": "ログ",
|
||||
"reboot": "再起動"
|
||||
},
|
||||
"table": {
|
||||
"date": "日付"
|
||||
},
|
||||
"pagination": {
|
||||
"next": "次",
|
||||
"prev": "前"
|
||||
},
|
||||
"displayName": "表示名",
|
||||
"username": "ユーザー名",
|
||||
"dialog": {
|
||||
@@ -32,14 +20,7 @@
|
||||
"offline": "Cloudronはオフラインです。再接続中…"
|
||||
},
|
||||
"apps": {
|
||||
"tagsFilterHeaderAll": "タグ一覧",
|
||||
"domainsFilterHeader": "ドメイン一覧",
|
||||
"tagsFilterHeader": "タグ: {{ tags }}",
|
||||
"searchPlaceholder": "アプリを探す",
|
||||
"adminPageActionTooltip": "管理者ページ",
|
||||
"infoActionTooltip": "情報",
|
||||
"logsActionTooltip": "ログ",
|
||||
"configActionTooltip": "設定",
|
||||
"noAccess": {
|
||||
"description": "アクセス権のあるアプリは、ここにに表示されます。",
|
||||
"title": "アプリへのアクセス権がありません。"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,33 +15,11 @@
|
||||
"userManagementNone": "Ta aplikacja posiada własne zarządzanie użytkownikami.",
|
||||
"userManagement": "Zarządanie użytkownikami",
|
||||
"manualWarning": "Manualnie dodaj rekord A dla <b>{{ location }}</b> do publicznego IP tego Cloudrona",
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>.",
|
||||
"lowOnResources": "Ten Cloudron jest blisko wyczerpania dostępnych zasobów."
|
||||
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>."
|
||||
},
|
||||
"unstable": "Niestabilne",
|
||||
"appMissing": "Szukasz innej aplikacji? Daj nam znać.",
|
||||
"noAppsFound": "Nie znaleziono żadnych aplikacji.",
|
||||
"searchPlaceholder": "Szukaj alternatyw jak Github, Dropbox, Slack, Trello, ...",
|
||||
"category": {
|
||||
"vpn": "VPN",
|
||||
"wiki": "Wiki",
|
||||
"project": "Zarządzanie projetkami",
|
||||
"sync": "Synchronizacja plików",
|
||||
"learning": "Nauka",
|
||||
"notes": "Notatki",
|
||||
"media": "Media",
|
||||
"git": "Hostowanie kodu",
|
||||
"hosting": "Web Hosting",
|
||||
"game": "Gry",
|
||||
"email": "Email",
|
||||
"finance": "Finanse",
|
||||
"gallery": "Galeria",
|
||||
"forum": "Forum",
|
||||
"crm": "CRM",
|
||||
"document": "Dokumenty",
|
||||
"blog": "Blog",
|
||||
"chat": "Czat",
|
||||
"analytics": "Analityka",
|
||||
"newApps": "Nowe aplikacje",
|
||||
"popular": "Popularne",
|
||||
"all": "Wszystko"
|
||||
@@ -52,26 +30,12 @@
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Zrestartuj teraz",
|
||||
"description": "Restartuj serwer by sfinalizowac instalacje aktualizacji bezpieczeństwa lub w przypadku nieoczekiwanych zachowań. Wszytskie usługi i aplikacje aktywne na tym Cloudronie zostaną automatycznie uruchomione ponownie po restarcie.",
|
||||
"warning": "Restart serwera spowoduje tymczasową niedostepność wszystkich aplikacji zainstalowanych na tym Cloudronie!",
|
||||
"title": "Na pewno zrestartować serwer?"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopyBackupId": "Kliknij by skopiowac Backup ID",
|
||||
"clickToCopy": "Kliknij by skopiować",
|
||||
"copied": "Skopiowano do schowka"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Logi",
|
||||
"reboot": "Restart"
|
||||
},
|
||||
"pagination": {
|
||||
"perPageSelector": "Pokazuj {{ n }} na stronie",
|
||||
"prev": "Poprzednia",
|
||||
"next": "Następna"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Akcje",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"username": "Użytkownik",
|
||||
@@ -86,15 +50,7 @@
|
||||
"offline": "Cloudron jest niedostępny. Odnawiam połączenie…"
|
||||
},
|
||||
"apps": {
|
||||
"domainsFilterHeader": "Wszytskie domeny",
|
||||
"tagsFilterHeaderAll": "Wszystkie tagi",
|
||||
"tagsFilterHeader": "Tagi: {{ tags }}",
|
||||
"stateFilterHeader": "Wszytskie stany",
|
||||
"searchPlaceholder": "Szukaj Aplikacji",
|
||||
"adminPageActionTooltip": "Panel Administratora",
|
||||
"infoActionTooltip": "Informacje",
|
||||
"logsActionTooltip": "Logi",
|
||||
"configActionTooltip": "Konfiguracja",
|
||||
"noAccess": {
|
||||
"description": "Po uzyskaniu dostępu będą one widoczne tutaj.",
|
||||
"title": "Nie masz obecnie dostępu do żadnych aplikacji."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,6 @@
|
||||
"main": {
|
||||
"logout": "නික්මෙන්න",
|
||||
"actions": "ක්රියාමාර්ග",
|
||||
"prettyDate": {
|
||||
"minutesAgo": "විනාඩි {{ m }} ට පෙර",
|
||||
"hoursAgo": "හෝරා {{ h }} ට පෙර",
|
||||
"justNow": "මේ දැන්",
|
||||
"yeserday": "ඊයේ"
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "අවලංගු",
|
||||
"save": "සුරකින්න",
|
||||
@@ -16,13 +10,6 @@
|
||||
"yes": "ඔව්"
|
||||
},
|
||||
"username": "පරිශීලක නාමය",
|
||||
"table": {
|
||||
"date": "දිනය"
|
||||
},
|
||||
"pagination": {
|
||||
"prev": "පෙර",
|
||||
"next": "ඊළඟ"
|
||||
},
|
||||
"searchPlaceholder": "සොයන්න",
|
||||
"multiselect": {
|
||||
"select": "තෝරන්න"
|
||||
@@ -30,35 +17,18 @@
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
"chat": "සම්භාෂණය",
|
||||
"learning": "ඉගෙනීම",
|
||||
"project": "ව්යාපෘති කළමනාකරණය",
|
||||
"all": "සියල්ල",
|
||||
"popular": "ජනප්රිය",
|
||||
"newApps": "නව යෙදුම්",
|
||||
"analytics": "විශ්ලේෂ",
|
||||
"document": "ලේඛන",
|
||||
"crm": "පා.ස.ක. (CRM)",
|
||||
"finance": "මූල්ය",
|
||||
"email": "වි-තැපෑල",
|
||||
"game": "ක්රීඩා",
|
||||
"media": "මාධ්ය",
|
||||
"notes": "සටහන්"
|
||||
"newApps": "නව යෙදුම්"
|
||||
},
|
||||
"title": "යෙදුම් ගබඩාව",
|
||||
"installDialog": {
|
||||
"location": "ස්ථානය",
|
||||
"groups": "සමූහ"
|
||||
},
|
||||
"accountDialog": {
|
||||
"password": "මුරපදය",
|
||||
"email": "වි-තැපෑල"
|
||||
}
|
||||
},
|
||||
"apps": {
|
||||
"title": "මාගේ යෙදුම්",
|
||||
"infoActionTooltip": "තොරතුරු",
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න",
|
||||
"domainsFilterHeader": "සියලුම වසම්"
|
||||
"searchPlaceholder": "යෙදුම් සොයන්න"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
+324
-292
@@ -1,24 +1,31 @@
|
||||
<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';
|
||||
import AppstoreView from './views/AppstoreView.vue';
|
||||
import BackupSitesView from './views/BackupSitesView.vue';
|
||||
import BackupAppArchiveView from './views/BackupAppArchiveView.vue';
|
||||
import BackupListView from './views/BackupListView.vue';
|
||||
import AppArchiveView from './views/AppArchiveView.vue';
|
||||
import CloudronAccountView from './views/CloudronAccountView.vue';
|
||||
import DomainsView from './views/DomainsView.vue';
|
||||
import EmailDomainView from './views/EmailDomainView.vue';
|
||||
@@ -29,57 +36,222 @@ 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 SystemLocaleView from './views/SystemLocaleView.vue';
|
||||
import SystemSettingsView from './views/SystemSettingsView.vue';
|
||||
import SystemUpdateView from './views/SystemUpdateView.vue';
|
||||
import SystemDockerView from './views/SystemDockerView.vue';
|
||||
import MetricsView from './views/MetricsView.vue';
|
||||
import DockerView from './views/DockerView.vue';
|
||||
import ServerView from './views/ServerView.vue';
|
||||
import UserDirectorySettingsView from './views/UserDirectorySettingsView.vue';
|
||||
import UserDirectoryLdapServerView from './views/UserDirectoryLdapServerView.vue';
|
||||
import UserDirectoryOpenIdProviderView from './views/UserDirectoryOpenIdProviderView.vue';
|
||||
import LdapView from './views/LdapView.vue';
|
||||
import OpenIdView from './views/OpenIdView.vue';
|
||||
import UsersView from './views/UsersView.vue';
|
||||
import GroupsView from './views/GroupsView.vue';
|
||||
import VolumesView from './views/VolumesView.vue';
|
||||
|
||||
const VIEWS = {
|
||||
APP: 'app',
|
||||
APPEARANCE: 'appearance',
|
||||
APPS: 'apps',
|
||||
APPSTORE: 'appstore',
|
||||
BACKUP_SITES: 'backup-sites',
|
||||
BACKUP_LIST: 'backup-list',
|
||||
BACKUP_APP_ARCHIVE: 'backup-app-archive',
|
||||
CLOUDRON_ACCOUNT: 'cloudron-account',
|
||||
DOMAINS: 'domains',
|
||||
EMAIL_DOMAIN: 'email-domain',
|
||||
EMAIL_DOMAINS: 'email-domains',
|
||||
EMAIL_MAILBOXES: 'email-mailboxes',
|
||||
EMAIL_MAILINGLISTS: 'email-mailinglists',
|
||||
EMAIL_SETTINGS: 'email-settings',
|
||||
EMAIL_EVENTLOG: 'email-eventlog',
|
||||
METRICS: 'metrics',
|
||||
NETWORK: 'network',
|
||||
PROFILE: 'profile',
|
||||
SERVICES: 'services',
|
||||
SYSTEM_LOCALE: 'system-locale',
|
||||
SYSTEM_DOCKER: 'system-docker',
|
||||
SYSTEM_EVENTLOG: 'system-eventlog',
|
||||
SYSTEM_UPDATE: 'system-update',
|
||||
USER_DIRECTORY_SETTINGS: 'user-directory-settings',
|
||||
USER_DIRECTORY_LDAP_SERVER: 'user-directory-ldap-server',
|
||||
USER_DIRECTORY_OPENID_PROVIDER: 'user-directory-openid-provider',
|
||||
USERS: 'users',
|
||||
GROUPS: 'users-groups',
|
||||
VOLUMES: 'volumes',
|
||||
};
|
||||
const VIEWS = Object.freeze({
|
||||
APP: '#/app', // this is a prefix
|
||||
APPEARANCE: '#/appearance',
|
||||
APPS: '#/apps',
|
||||
APPSTORE: '#/appstore', // this is a prefix
|
||||
BACKUP_SITES: '#/backup-sites',
|
||||
APP_ARCHIVE: '#/app-archive',
|
||||
CLOUDRON_ACCOUNT: '#/cloudron-account',
|
||||
DOMAINS: '#/domains',
|
||||
EMAIL_DOMAIN: '#/email-domain',
|
||||
EMAIL_DOMAINS: '#/email-domains',
|
||||
MAILBOXES: '#/mailboxes',
|
||||
MAILINGLISTS: '#/mailinglists',
|
||||
EMAIL_SETTINGS: '#/email-settings',
|
||||
EMAIL_EVENTLOG: '#/email-eventlog',
|
||||
SERVER: '#/server',
|
||||
NETWORK: '#/network',
|
||||
NOTIFICATIONS: '#/notifications',
|
||||
PROFILE: '#/profile',
|
||||
SERVICES: '#/services',
|
||||
SYSTEM_SETTINGS: '#/system-settings',
|
||||
DOCKER: '#/docker',
|
||||
SYSTEM_EVENTLOG: '#/system-eventlog',
|
||||
SYSTEM_UPDATE: '#/system-update',
|
||||
USER_DIRECTORY_SETTINGS: '#/user-directory-settings',
|
||||
LDAP: '#/ldap',
|
||||
OPENID: '#/openid',
|
||||
USERS: '#/users',
|
||||
GROUPS: '#/groups',
|
||||
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');
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
// network error, request killed by browser
|
||||
if (error instanceof TypeError) {
|
||||
@@ -105,12 +277,16 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
|
||||
const 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: {},
|
||||
});
|
||||
@@ -118,32 +294,8 @@ const config = ref({});
|
||||
const avatarUrl = ref('');
|
||||
const features = ref({});
|
||||
|
||||
function onSidebarClose() {
|
||||
sidebar.value.close();
|
||||
}
|
||||
|
||||
const activeSidebarItem = ref('');
|
||||
const activeSidebarGroups = ref({
|
||||
'backup': false,
|
||||
'email': false,
|
||||
'system': false,
|
||||
'user-directory': false,
|
||||
'users': false
|
||||
});
|
||||
function onToggleGroup(group) {
|
||||
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const v = location.hash.slice(2);
|
||||
|
||||
activeSidebarItem.value = v;
|
||||
|
||||
if (activeSidebarItem.value.indexOf('backup') === 0) activeSidebarGroups.value['backup'] = true;
|
||||
else if (activeSidebarItem.value.indexOf('email') === 0) activeSidebarGroups.value['email'] = true;
|
||||
else if (activeSidebarItem.value.indexOf('system') === 0) activeSidebarGroups.value['system'] = true;
|
||||
else if (activeSidebarItem.value.indexOf('user-directory') === 0) activeSidebarGroups.value['user-directory'] = true;
|
||||
else if (activeSidebarItem.value.indexOf('users') === 0) activeSidebarGroups.value['users'] = true;
|
||||
const v = window.location.hash.split('?')[0];
|
||||
|
||||
if (v === VIEWS.APPS) {
|
||||
view.value = VIEWS.APPS;
|
||||
@@ -155,10 +307,8 @@ function onHashChange() {
|
||||
view.value = VIEWS.APPEARANCE;
|
||||
} else if (v === VIEWS.BACKUP_SITES && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.BACKUP_SITES;
|
||||
} else if (v === VIEWS.BACKUP_LIST && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.BACKUP_LIST;
|
||||
} else if (v === VIEWS.BACKUP_APP_ARCHIVE && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.BACKUP_APP_ARCHIVE;
|
||||
} else if (v === VIEWS.APP_ARCHIVE && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.APP_ARCHIVE;
|
||||
} else if (v === VIEWS.CLOUDRON_ACCOUNT && profile.value.isAtLeastOwner) {
|
||||
view.value = VIEWS.CLOUDRON_ACCOUNT;
|
||||
} else if (v === VIEWS.DOMAINS && profile.value.isAtLeastAdmin) {
|
||||
@@ -167,36 +317,38 @@ function onHashChange() {
|
||||
view.value = VIEWS.EMAIL_DOMAIN;
|
||||
} else if (v === VIEWS.EMAIL_DOMAINS && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.EMAIL_DOMAINS;
|
||||
} else if (v === VIEWS.EMAIL_MAILBOXES && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.EMAIL_MAILBOXES;
|
||||
} else if (v === VIEWS.EMAIL_MAILINGLISTS && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.EMAIL_MAILINGLISTS;
|
||||
} else if (v === VIEWS.MAILBOXES && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.MAILBOXES;
|
||||
} else if (v === VIEWS.MAILINGLISTS && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.MAILINGLISTS;
|
||||
} else if (v === VIEWS.EMAIL_SETTINGS && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.EMAIL_SETTINGS;
|
||||
} else if (v === VIEWS.EMAIL_EVENTLOG && profile.value.isAtLeastMailManager) {
|
||||
view.value = VIEWS.EMAIL_EVENTLOG;
|
||||
} else if (v === VIEWS.METRICS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.METRICS;
|
||||
} 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.indexOf(VIEWS.PROFILE) === 0) {
|
||||
} else if (v === VIEWS.PROFILE) {
|
||||
view.value = VIEWS.PROFILE;
|
||||
} else if (v === VIEWS.SERVICES && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SERVICES;
|
||||
} else if (v === VIEWS.SYSTEM_LOCALE && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SYSTEM_LOCALE;
|
||||
} else if (v === VIEWS.SYSTEM_DOCKER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SYSTEM_DOCKER;
|
||||
} else if (v.indexOf(VIEWS.SYSTEM_EVENTLOG) === 0 && profile.value.isAtLeastAdmin) {
|
||||
} else if (v === VIEWS.SYSTEM_SETTINGS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SYSTEM_SETTINGS;
|
||||
} else if (v === VIEWS.DOCKER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.DOCKER;
|
||||
} else if (v === VIEWS.SYSTEM_EVENTLOG && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SYSTEM_EVENTLOG;
|
||||
} else if (v === VIEWS.SYSTEM_UPDATE && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SYSTEM_UPDATE;
|
||||
} else if (v === VIEWS.USER_DIRECTORY_SETTINGS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.USER_DIRECTORY_SETTINGS;
|
||||
} else if (v === VIEWS.USER_DIRECTORY_LDAP_SERVER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.USER_DIRECTORY_LDAP_SERVER;
|
||||
} else if (v === VIEWS.USER_DIRECTORY_OPENID_PROVIDER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.USER_DIRECTORY_OPENID_PROVIDER;
|
||||
} else if (v === VIEWS.LDAP && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.LDAP;
|
||||
} else if (v === VIEWS.OPENID && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.OPENID;
|
||||
} else if (v === VIEWS.USERS && profile.value.isAtLeastUserManager) {
|
||||
view.value = VIEWS.USERS;
|
||||
} else if (v === VIEWS.GROUPS && profile.value.isAtLeastUserManager) {
|
||||
@@ -204,7 +356,7 @@ function onHashChange() {
|
||||
} else if (v === VIEWS.VOLUMES && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.VOLUMES;
|
||||
} else {
|
||||
window.location.hash = '/' + VIEWS.APPS;
|
||||
window.location.href = VIEWS.APPS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,54 +376,111 @@ 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) {
|
||||
localStorage.setItem('version', result.version);
|
||||
} else if (result.version !== currentVersion) {
|
||||
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;
|
||||
features.value = result.features;
|
||||
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 (document.querySelector('link[rel="icon"]')) document.querySelector('link[rel="icon"]').href = `${API_ORIGIN}/api/v1/cloudron/avatar?ts=${Date.now()}`;
|
||||
|
||||
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>
|
||||
@@ -281,82 +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: activeSidebarItem === 'apps' }" href="#/apps" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'appstore' }" v-show="profile.isAtLeastAdmin" href="#/appstore" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
|
||||
<hr/>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'appearance' }" v-show="profile.isAtLeastAdmin" href="#/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('backup')"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['backup'] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups['backup']">
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-sites' }" href="#/backup-sites" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-list' }" href="#/backup-list" @click="onSidebarClose()"><i class="fa fa-fw fa-list-check"></i> {{ $t('backups.listing.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-app-archive' }" href="#/backup-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: activeSidebarItem === 'domains' }" v-show="profile.isAtLeastAdmin" href="#/domains" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup('email')"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['email'] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups['email']">
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-domains' }" href="#/email-domains" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-mailboxes' }" href="#/email-mailboxes" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-mailinglists' }" href="#/email-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: activeSidebarItem === 'email-eventlog' }" href="#/email-eventlog" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-settings' }" href="#/email-settings" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('emails.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'network' }" v-show="profile.isAtLeastAdmin" href="#/network" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'services' }" v-show="profile.isAtLeastAdmin" href="#/services" @click="onSidebarClose()"><i class="fa fa-cogs fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup('system')"><i class="fa fa-wrench fa-fw"></i> {{ $t('settings.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['system'] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups['system']">
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-docker' }" href="#/system-docker" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> {{ $t('dockerRegistries.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-eventlog' }" href="#/system-eventlog" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-locale' }" href="#/system-locale" @click="onSidebarClose()"><i class="fa fa-fw fa-language"></i> {{ $t('system.locale.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-update' }" href="#/system-update" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup('users')"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['users'] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups['users']">
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'users' }" v-show="profile.isAtLeastUserManager" href="#/users" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'users-groups' }" v-show="profile.isAtLeastUserManager" href="#/users-groups" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup('user-directory')"><i class="fa fa-address-book fa-fw"></i> {{ $t('userDirectory.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['user-directory'] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups['user-directory']">
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-ldap-server' }" href="#/user-directory-ldap-server" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> {{ $t('users.exposedLdap.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-openid-provider' }" href="#/user-directory-openid-provider" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> {{ $t('oidc.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-settings' }" href="#/user-directory-settings" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'volumes' }" v-show="profile.isAtLeastAdmin" href="#/volumes" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
|
||||
|
||||
<hr v-show="profile.isAtLeastAdmin"/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'metrics' }" v-show="profile.isAtLeastAdmin" href="#/metrics" @click="onSidebarClose()"><i class="fa fa-chart-area fa-fw"></i> {{ $t('system.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'cloudron-account' }" v-show="profile.isAtLeastOwner" href="#/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>
|
||||
@@ -366,27 +507,27 @@ onMounted(async () => {
|
||||
<AppConfigureView v-if="view === VIEWS.APP" />
|
||||
<AppearanceView v-else-if="view === VIEWS.APPEARANCE" />
|
||||
<BackupSitesView v-else-if="view === VIEWS.BACKUP_SITES" />
|
||||
<BackupListView v-else-if="view === VIEWS.BACKUP_LIST" />
|
||||
<BackupAppArchiveView v-else-if="view === VIEWS.BACKUP_APP_ARCHIVE" />
|
||||
<AppArchiveView v-else-if="view === VIEWS.APP_ARCHIVE" />
|
||||
<CloudronAccountView v-else-if="view === VIEWS.CLOUDRON_ACCOUNT" />
|
||||
<DomainsView v-else-if="view === VIEWS.DOMAINS" />
|
||||
<EmailDomainsView v-else-if="view === VIEWS.EMAIL_DOMAINS" />
|
||||
<EmailDomainView v-else-if="view === VIEWS.EMAIL_DOMAIN" />
|
||||
<EmailMailboxesView v-else-if="view === VIEWS.EMAIL_MAILBOXES" />
|
||||
<EmailMailinglistsView v-else-if="view === VIEWS.EMAIL_MAILINGLISTS" />
|
||||
<EmailMailboxesView v-else-if="view === VIEWS.MAILBOXES" />
|
||||
<EmailMailinglistsView v-else-if="view === VIEWS.MAILINGLISTS" />
|
||||
<EmailSettingsView v-else-if="view === VIEWS.EMAIL_SETTINGS" />
|
||||
<EmailEventlogView v-else-if="view === VIEWS.EMAIL_EVENTLOG" />
|
||||
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
||||
<MetricsView v-else-if="view === VIEWS.METRICS" />
|
||||
<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" />
|
||||
<SystemLocaleView v-else-if="view === VIEWS.SYSTEM_LOCALE" />
|
||||
<SystemDockerView v-else-if="view === VIEWS.SYSTEM_DOCKER" />
|
||||
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
||||
<DockerView v-else-if="view === VIEWS.DOCKER" />
|
||||
<SystemUpdateView v-else-if="view === VIEWS.SYSTEM_UPDATE" />
|
||||
<UserDirectorySettingsView v-else-if="view === VIEWS.USER_DIRECTORY_SETTINGS" />
|
||||
<UserDirectoryLdapServerView v-else-if="view === VIEWS.USER_DIRECTORY_LDAP_SERVER" />
|
||||
<UserDirectoryOpenIdProviderView v-else-if="view === VIEWS.USER_DIRECTORY_OPENID_PROVIDER" />
|
||||
<LdapView v-else-if="view === VIEWS.LDAP" />
|
||||
<OpenIdView v-else-if="view === VIEWS.OPENID" />
|
||||
<UsersView v-else-if="view === VIEWS.USERS" />
|
||||
<GroupsView v-else-if="view === VIEWS.GROUPS" />
|
||||
<VolumesView v-else-if="view === VIEWS.VOLUMES" />
|
||||
@@ -395,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>
|
||||
|
||||
@@ -1,69 +1,75 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
import { ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const props = defineProps([ 'manifest', 'error', 'hideOptionalSsoOption' ]);
|
||||
const props = defineProps({
|
||||
users: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
manifest: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
sso: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
installation: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const accessRestrictionOption = defineModel('option');
|
||||
const accessRestriction = defineModel('acl');
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
const optionalSso = !!props.manifest.optionalSso;
|
||||
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
const optionalSso = computed(() => {
|
||||
return !!props.manifest.optionalSso && props.installation;
|
||||
});
|
||||
const cloudronAuth = computed(() => {
|
||||
return !(!props.installation && !props.sso) && !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<FormGroup v-show="manifest.addons.email">
|
||||
<label>{{ $t('appstore.installDialog.userManagement') }}</label>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.userManagementMailbox') }}
|
||||
<span v-html="$t('appstore.installDialog.configuredForCloudronEmail', { emailDocsLink: 'https://docs.cloudron.io/email/' })"></span>
|
||||
</div>
|
||||
<FormGroup>
|
||||
<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>
|
||||
</FormGroup>
|
||||
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<FormGroup>
|
||||
<label v-show="cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagement') }} <sup><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-show="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
|
||||
|
||||
<label v-show="!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>
|
||||
<div v-show="!cloudronAuth || manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
|
||||
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED">
|
||||
<div style="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" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<div>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
|
||||
<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="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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<span class="dots"></span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.dots {
|
||||
display: inline-block;
|
||||
width: 1.5em; /* reserve enough space for '...' */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dots::after {
|
||||
content: '';
|
||||
animation: dots 1.5s steps(4, end) infinite;
|
||||
}
|
||||
|
||||
@keyframes dots {
|
||||
0% { content: ''; }
|
||||
25% { content: '.'; }
|
||||
50% { content: '..'; }
|
||||
75% { content: '...'; }
|
||||
100% { content: ''; }
|
||||
}
|
||||
|
||||
</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, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { copyToClipboard, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
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;
|
||||
@@ -89,11 +91,6 @@ async function onSubmitAddApiToken(){
|
||||
await refreshApiTokens();
|
||||
}
|
||||
|
||||
function onCopyToClipboard(apiToken) {
|
||||
copyToClipboard(apiToken);
|
||||
window.pankow.notify({ type: 'success', text: 'API Token copied!' });
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
setTimeout(() => {
|
||||
addedToken.value = '';
|
||||
@@ -101,15 +98,18 @@ function onReset() {
|
||||
tokenScope.value = 'rw';
|
||||
tokenAllowedIpRanges.value = '';
|
||||
tokenAllowedIpRangesError.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function onRevokeToken(apiToken) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeApiToken.title', { name: apiToken.name }),
|
||||
title: t('profile.removeApiToken.title'),
|
||||
message: t('profile.removeApiToken.description', { name: apiToken.name }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -128,14 +128,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createApiToken.title')"
|
||||
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
|
||||
:confirm-label="addedToken ? '' : $t('main.action.add')"
|
||||
:confirm-active="isFormValid"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmitAddApiToken()"
|
||||
@close="onReset()"
|
||||
@@ -143,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/>
|
||||
@@ -159,7 +159,8 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('profile.createApiToken.allowedIpRanges') }}</label>
|
||||
<div class="has-error" v-show="tokenAllowedIpRangesError">{{ tokenAllowedIpRangesError }}</div>
|
||||
<TextInput v-model="tokenAllowedIpRanges" :placeholder="$t('profile.apiTokens.allowedIpRangesPlaceholder')" />
|
||||
<TextInput v-model="tokenAllowedIpRanges" />
|
||||
<small class="helper-text">{{ $t('profile.apiTokens.allowedIpRangesPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
@@ -167,7 +168,7 @@ onMounted(async () => {
|
||||
<p>{{ $t('profile.createApiToken.description') }}</p>
|
||||
<InputGroup>
|
||||
<TextInput v-model="addedToken" readonly style="flex-grow: 1;"/>
|
||||
<Button tool @click="onCopyToClipboard(addedToken)" icon="fa fa-clipboard" />
|
||||
<ClipboardButton :value="addedToken" />
|
||||
</InputGroup>
|
||||
<p>{{ $t('profile.createApiToken.copyNow') }}</p>
|
||||
</div>
|
||||
@@ -184,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 } 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,102 +10,132 @@ 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 backupConfig = {};
|
||||
const config = {};
|
||||
|
||||
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
|
||||
|
||||
// only set provider specific fields, this will clear them in the db
|
||||
if (s3like(provider.value)) {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.accessKeyId = providerConfig.value.accessKeyId;
|
||||
backupConfig.secretAccessKey = providerConfig.value.secretAccessKey;
|
||||
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) backupConfig.endpoint = providerConfig.value.endpoint;
|
||||
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
|
||||
|
||||
if (provider.value === 's3') {
|
||||
if (providerConfig.value.region) backupConfig.region = providerConfig.value.region;
|
||||
delete backupConfig.endpoint;
|
||||
if (providerConfig.value.region) config.region = providerConfig.value.region;
|
||||
delete config.endpoint;
|
||||
} else if (provider.value === 'minio' || provider.value === 's3-v4-compat') {
|
||||
backupConfig.region = providerConfig.value.region || 'us-east-1';
|
||||
backupConfig.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
config.region = providerConfig.value.region || 'us-east-1';
|
||||
config.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
|
||||
config.s3ForcePathStyle = true; // might want to expose this in the UI
|
||||
} else if (provider.value === 'exoscale-sos') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'wasabi') {
|
||||
backupConfig.region = REGIONS_WASABI.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_WASABI.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'scaleway-objectstorage') {
|
||||
backupConfig.region = REGIONS_SCALEWAY.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_SCALEWAY.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'linode-objectstorage') {
|
||||
backupConfig.region = REGIONS_LINODE.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_LINODE.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ovh-objectstorage') {
|
||||
backupConfig.region = REGIONS_OVH.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_OVH.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'ionos-objectstorage') {
|
||||
backupConfig.region = REGIONS_IONOS.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_IONOS.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'vultr-objectstorage') {
|
||||
backupConfig.region = REGIONS_VULTR.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = REGIONS_VULTR.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'contabo-objectstorage') {
|
||||
backupConfig.region = REGIONS_CONTABO.find(function (x) { return x.value === backupConfig.endpoint; }).region;
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
config.region = REGIONS_CONTABO.find(function (x) { return x.value === config.endpoint; }).region;
|
||||
config.signatureVersion = 'v4';
|
||||
config.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
|
||||
} else if (provider.value === 'upcloud-objectstorage') {
|
||||
const m = /^.*\.(.*)\.upcloudobjects.com$/.exec(providerConfig.value.endpoint);
|
||||
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
config.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
|
||||
config.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'digitalocean-spaces') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
config.region = 'us-east-1';
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
backupConfig.region = 'us-east-1';
|
||||
backupConfig.signatureVersion = 'v4';
|
||||
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 = prefix;
|
||||
config.noHardlinks = !providerConfig.value.useHardlinks;
|
||||
config.mountOptions = {};
|
||||
|
||||
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
|
||||
config.mountOptions.host = providerConfig.value.mountOptionHost;
|
||||
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
|
||||
|
||||
if (provider.value === 'cifs') {
|
||||
config.mountOptions.username = providerConfig.value.mountOptionUsername;
|
||||
config.mountOptions.password = providerConfig.value.mountOptionPassword;
|
||||
config.mountOptions.seal = !!providerConfig.value.mountOptionSeal;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
} else if (provider.value === 'sshfs') {
|
||||
config.mountOptions.user = providerConfig.value.mountOptionUser;
|
||||
config.mountOptions.port = parseInt(providerConfig.value.mountOptionPort);
|
||||
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
|
||||
config.preserveAttributes = true;
|
||||
}
|
||||
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
|
||||
config.preserveAttributes = true;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
config.mountPoint = providerConfig.value.mountPoint;
|
||||
config.chown = !!providerConfig.value.chown;
|
||||
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
|
||||
}
|
||||
} else if (provider.value === 'gcs') {
|
||||
backupConfig.bucket = providerConfig.value.bucket;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.projectId = providerConfig.value.projectId;
|
||||
backupConfig.credentials = providerConfig.value.credentials;
|
||||
} else if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs' || provider.value === 'ext4' || provider.value === 'xfs') {
|
||||
backupConfig.mountOptions = providerConfig.value.mountOptions;
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
} else if (provider.value === 'mountpoint') {
|
||||
backupConfig.prefix = providerConfig.value.prefix;
|
||||
backupConfig.mountPoint = providerConfig.value.mountPoint;
|
||||
} else if (provider.value === 'filesystem') {
|
||||
const parts = remotePath.value.split('/');
|
||||
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
|
||||
backupConfig.backupDir = parts.join('/'); // this is dirname()
|
||||
config.backupDir = prefix;
|
||||
} else if (provider.value === 'gcs') {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.projectId = providerConfig.value.projectId;
|
||||
config.credentials = providerConfig.value.credentials;
|
||||
config.prefix = prefix;
|
||||
}
|
||||
|
||||
const data = {
|
||||
format: format.value,
|
||||
provider: provider.value,
|
||||
config: backupConfig,
|
||||
remotePath: backupPath
|
||||
config,
|
||||
remotePath
|
||||
};
|
||||
|
||||
if (encrypted.value) {
|
||||
@@ -166,22 +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;
|
||||
providerConfig.value = data.config;
|
||||
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;
|
||||
|
||||
providerConfig.value = {};
|
||||
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]);
|
||||
@@ -191,6 +264,10 @@ function onUploadBackupConfig() {
|
||||
backupConfigInput.value.click();
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
async open(id) {
|
||||
appId.value = id;
|
||||
@@ -198,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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,14 +295,18 @@ 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="busy ? '' : $t('main.dialog.cancel')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>{{ $t('app.importBackupDialog.description') }}</div>
|
||||
<div class="text-danger">{{ $t('app.importBackupDialog.warning') }}</div>
|
||||
|
||||
<!-- ideally, we get can get rid of this and just display this error from the imported config -->
|
||||
<p class="text-danger">{{ $t('app.importBackupDialog.versionMustMatchInfo') }}</p>
|
||||
|
||||
<p>{{ $t('app.importBackupDialog.provideBackupInfo') }}
|
||||
<input type="file" ref="backupConfigFileInput" @change="onBackupConfigChanged" accept="application/json, text/json" style="display:none"/>
|
||||
@@ -232,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"
|
||||
@@ -249,7 +332,7 @@ defineExpose({
|
||||
:form-error="formError"
|
||||
:import-only="true" />
|
||||
|
||||
<Checkbox style="padding-top: 12px" v-model="encrypted" :label="$t('backups.configureBackupStorage.usesEncryption')"/>
|
||||
<Checkbox v-model="encrypted" :label="$t('backups.configureBackupStorage.usesEncryption')"/>
|
||||
<FormGroup v-if="encrypted">
|
||||
<label for="encryptionPassswordInput">{{ $t('backups.configureBackupStorage.encryptionPassword') }}</label>
|
||||
<PasswordInput id="encryptionPassswordInput" v-model="encryptionPassword" :placeholder="$t('backups.configureBackupStorage.encryptionPasswordPlaceholder')" required/>
|
||||
|
||||
@@ -1,54 +1,73 @@
|
||||
<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 DashboardModel from '../models/DashboardModel.js';
|
||||
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.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'),
|
||||
});
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
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');
|
||||
const locationInput = useTemplateRef('locationInput');
|
||||
const description = computed(() => marked.parse(manifest.value.description || ''));
|
||||
const domains = ref([]);
|
||||
const dashboardDomain = 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);
|
||||
@@ -61,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
|
||||
@@ -74,22 +95,56 @@ const tcpPorts = ref({});
|
||||
const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const overwriteDns = ref(false);
|
||||
const needsOverwriteDns = ref([]);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
function onDomainChange() {
|
||||
const tmp = domains.value.find(d => d.domain === domain.value);
|
||||
domainProvider.value = tmp ? tmp.provider : '';
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
const checkForDomains = [{
|
||||
domain: domain.value,
|
||||
subdomain: location.value,
|
||||
}];
|
||||
|
||||
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) {
|
||||
formError.value.location = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
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 = {
|
||||
subdomain: location.value,
|
||||
domain: domain.value,
|
||||
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
|
||||
};
|
||||
|
||||
if (overwriteDns.value) config.overwriteDns = true;
|
||||
|
||||
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
|
||||
|
||||
const finalPorts = {};
|
||||
@@ -103,8 +158,7 @@ async function submit() {
|
||||
}
|
||||
config.ports = finalPorts;
|
||||
|
||||
const finalSecondaryDomains = {};
|
||||
for (var p in secondaryDomains.value) {
|
||||
const finalSecondaryDomains = {}; for (const p in secondaryDomains.value) {
|
||||
finalSecondaryDomains[p] = {
|
||||
subdomain: secondaryDomains.value[p].value,
|
||||
domain: secondaryDomains.value[p].domain
|
||||
@@ -114,12 +168,12 @@ async function submit() {
|
||||
|
||||
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;
|
||||
@@ -128,9 +182,8 @@ async function submit() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -140,10 +193,14 @@ function onClose() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => { u.label = u.displayName || u.username || u.email; });
|
||||
users.value = result;
|
||||
|
||||
dashboardDomain.value = result.adminDomain;
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
const screenshotsContainer = useTemplateRef('screenshotsContainer');
|
||||
@@ -165,17 +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 = '';
|
||||
overwriteDns.value = false;
|
||||
needsOverwriteDns.value = [];
|
||||
|
||||
domainList.forEach(d => {
|
||||
d.label = '.' + d.domain;
|
||||
@@ -184,10 +245,10 @@ defineExpose({
|
||||
domains.value = domainList;
|
||||
|
||||
// preselect with dashboard domain
|
||||
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).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) {
|
||||
@@ -199,15 +260,15 @@ 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;
|
||||
port.domain = domains.value[0].domain;
|
||||
port.domain = dashboardDomain.value;
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
|
||||
step.value = STEP.DETAILS;
|
||||
dialog.value.open();
|
||||
},
|
||||
close() {
|
||||
@@ -218,18 +279,20 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogHandle" @close="onClose()" 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">{{ 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" :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">
|
||||
@@ -244,42 +307,44 @@ defineExpose({
|
||||
<div class="description" v-html="description"></div>
|
||||
</div>
|
||||
<div v-else-if="step === STEP.INSTALL">
|
||||
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="submit()" 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="text-danger" v-if="formError.location">{{ formError.location }}</div>
|
||||
<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>
|
||||
</FormGroup>
|
||||
|
||||
<p class="text-small text-warning" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></p>
|
||||
|
||||
<FormGroup v-for="(port, key) in secondaryDomains" :key="key">
|
||||
<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"/>
|
||||
<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 @click="submit" 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>
|
||||
@@ -298,7 +363,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
.step-detail {
|
||||
width: 960px;
|
||||
width: 800px;
|
||||
}
|
||||
|
||||
.step-install {
|
||||
@@ -307,7 +372,6 @@ defineExpose({
|
||||
|
||||
.app-install-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -316,6 +380,7 @@ defineExpose({
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.summary > div {
|
||||
@@ -325,6 +390,8 @@ defineExpose({
|
||||
.title {
|
||||
font-size: 32px;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon {
|
||||
|
||||
@@ -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, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate, copyToClipboard } from '@cloudron/pankow/utils';
|
||||
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,66 +62,85 @@ 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();
|
||||
}
|
||||
|
||||
function onCopyToClipboard(password) {
|
||||
copyToClipboard(password);
|
||||
window.pankow.notify({ type: 'success', text: 'Password copied!' });
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function onRemove(appPassword) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: t('profile.removeAppPassword.title', { name: appPassword.name }),
|
||||
title: t('profile.removeAppPassword.title'),
|
||||
message: t('profile.removeAppPassword.description', { name: appPassword.name }),
|
||||
confirmLabel: t('main.action.remove'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no')
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -161,39 +185,48 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="addedPassword || (!busy && isFormValid)"
|
||||
:confirm-label="addedPassword ? '' : $t('main.action.add')"
|
||||
confirm-style="primary"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
@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>
|
||||
<p>{{ $t('profile.createAppPassword.description') }}</p>
|
||||
<InputGroup>
|
||||
<TextInput v-model="addedPassword" readonly style="flex-grow: 1;"/>
|
||||
<Button tool @click="onCopyToClipboard(addedPassword)" icon="fa fa-clipboard" />
|
||||
<ClipboardButton :value="addedPassword" />
|
||||
</InputGroup>
|
||||
<p>{{ $t('profile.createAppPassword.copyNow') }}</p>
|
||||
</div>
|
||||
@@ -210,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>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
// for restore from archive or clone !
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { InputGroup, FormGroup, TextInput, SingleSelect, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import PortBindings from '../components/PortBindings.vue';
|
||||
@@ -14,6 +14,7 @@ const appsModel = AppsModel.create();
|
||||
const archivesModel = ArchivesModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const appId = ref(null);
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const restoreArchive = ref({});
|
||||
@@ -119,7 +120,7 @@ defineExpose({
|
||||
|
||||
const app = archive.appConfig || {
|
||||
subdomain: '',
|
||||
domain: domains.value[0].domain,
|
||||
domain: dashboardDomain.value,
|
||||
secondaryDomains: [],
|
||||
portBindings: {}
|
||||
}; // pre-8.2 backups do not have appConfig
|
||||
@@ -129,7 +130,7 @@ defineExpose({
|
||||
|
||||
restoreLocation.value = app.subdomain;
|
||||
const d = domains.value.find(function (d) { return app.domain === d.domain; });
|
||||
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
|
||||
restoreDomain.value = d ? d.domain : dashboardDomain.value; // try to pre-select the app's domain
|
||||
restoreSecondaryDomains.value = {};
|
||||
needsOverwrite.value = false;
|
||||
restoreArchive.value = archive;
|
||||
@@ -190,9 +191,10 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="appId ? $t('app.cloneDialog.title', { app: fqdn }) : $t('backups.restoreArchiveDialog.title')"
|
||||
:title="appId ? $t('app.cloneDialog.title') : $t('backups.restoreArchiveDialog.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="busy ? '' : $t('main.dialog.cancel')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
:confirm-label="!appId ? $t(needsOverwrite ? 'backups.restoreArchiveDialog.restoreActionOverwrite' : 'backups.restoreArchiveDialog.restoreAction') : $t(needsOverwrite ? 'app.restoreDialog.cloneActionOverwrite' : 'app.restoreDialog.cloneAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy"
|
||||
@@ -206,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
|
||||
@@ -98,8 +102,9 @@ async function onRemove() {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
message: `Really remove applink?`,
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -126,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();
|
||||
@@ -133,6 +139,8 @@ defineExpose({
|
||||
groups.value = result;
|
||||
|
||||
applinkDialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,17 +153,17 @@ defineExpose({
|
||||
alternate-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
: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>
|
||||
|
||||
@@ -171,17 +179,18 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<div>
|
||||
<label for="previewIcon">{{ $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"/>
|
||||
<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>
|
||||
|
||||
<FormGroup>
|
||||
<label for="applinkTags">{{ $t('app.display.tags') }}</label>
|
||||
<TagInput id="applinkTags" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
|
||||
<TagInput id="applinkTags" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
|
||||
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
|
||||
</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> -->
|
||||
@@ -190,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>
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
|
||||
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';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
|
||||
const busy = ref(true);
|
||||
|
||||
const backupContentTableColumns = computed(() => {
|
||||
const columns = {
|
||||
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;
|
||||
},
|
||||
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');
|
||||
|
||||
defineExpose({
|
||||
async open(b) {
|
||||
backup.value = JSON.parse(JSON.stringify(b)); // make a copy
|
||||
backup.value.contents = [];
|
||||
backup.value.validStats = false; // old cloudron version had invalid stats
|
||||
busy.value = true;
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
if (backup.value.type === 'app') {
|
||||
backup.value.validStats = backup.value.stats?.upload && backup.value.stats?.copy;
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// amend detailed app info
|
||||
const appsById = {};
|
||||
|
||||
const [appsError, apps] = await appsModel.list();
|
||||
if (appsError) console.error('Failed to get apps list:', appsError);
|
||||
|
||||
(apps || []).forEach(function (app) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const contentId of backup.value.dependsOn) {
|
||||
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
|
||||
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, 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';
|
||||
} else {
|
||||
const app = appsById[match[2]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else { // uninstalled app
|
||||
content.id = match[2];
|
||||
}
|
||||
}
|
||||
backup.value.contents.push(content);
|
||||
}
|
||||
|
||||
backup.value.validStats = backup.value.stats?.aggregatedUpload && backup.value.stats?.aggregatedCopy;
|
||||
busy.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ backup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.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>
|
||||
<div class="info-value">
|
||||
<div>
|
||||
{{ backup.remotePath }}
|
||||
<ClipboardAction plain :value="backup.remotePath"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(backup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ backup.packageVersion }}</div>
|
||||
</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) | {{ 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">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
|
||||
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.lastIntegrityCheck') }}</div>
|
||||
<div class="info-value">
|
||||
<a v-if="backup.integrityCheckTask?.active" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">{{ $t('backups.backupDetails.integrityInProgress') }}</a>
|
||||
<a v-else-if="backup.lastIntegrityCheckTime && backup.integrityCheckTask" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</a>
|
||||
<span v-else-if="backup.lastIntegrityCheckTime">
|
||||
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
|
||||
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
|
||||
</span>
|
||||
<span v-else>{{ $t('backups.backupDetails.integrityNever') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="(backup.integrityCheckStatus === 'failed' || backup.integrityCheckStatus === 'skipped') && backup.integrityCheckResult?.messages?.length">
|
||||
<div class="info-label" style="margin-bottom: 5px;">Integrity Issues</div>
|
||||
<textarea readonly rows="10" style="width: 100%; resize: vertical;" :value="backup.integrityCheckResult.messages.join('\n')"></textarea>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
|
||||
|
||||
<div v-if="backup.type === 'box'">
|
||||
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
|
||||
<template #label="{ 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="{ 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="{ 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>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { ref, onMounted, watch, watchEffect } from 'vue';
|
||||
import { Button, InputGroup, SingleSelect, FormGroup, TextInput, Checkbox, PasswordInput, NumberInput } from '@cloudron/pankow';
|
||||
import { BACKUP_FORMATS, STORAGE_PROVIDERS, REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_EXOSCALE, REGIONS_DIGITALOCEAN, REGIONS_HETZNER, REGIONS_WASABI, REGIONS_S3 } from '../constants.js';
|
||||
import ProvisionModel from '../models/ProvisionModel.js';
|
||||
@@ -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,17 @@ 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');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await getBlockDevices();
|
||||
});
|
||||
@@ -108,44 +123,45 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="error-label" v-show="formError.generic">{{ formError.generic }}</div>
|
||||
<!-- when provisioning, the error has to be shown on top of the backup path in the view itself -->
|
||||
<div class="error-label" v-if="!provisioning && formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('backups.configureBackupStorage.provider') }} <sup><a href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" />
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- mountpoint -->
|
||||
<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 -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }} ({{ provider }})</label>
|
||||
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }}</label>
|
||||
<TextInput id="mountOptionHostInput" v-model="providerConfig.mountOptionHost" placeholder="Server IP or hostname" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
|
||||
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS -->
|
||||
<Checkbox v-if="provider === 'cifs'" v-model="providerConfig.mountOptionSeal" :label="$t('backups.configureBackupStorage.cifsSealSupport')" />
|
||||
|
||||
<!-- CIFS/NFS/SSHFS -->
|
||||
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }} ({{ provider }})</label>
|
||||
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
|
||||
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }}</label>
|
||||
<TextInput id="mountOptionUsernameInput" v-model="providerConfig.mountOptionUsername" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- CIFS -->
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
|
||||
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }}</label>
|
||||
<PasswordInput id="mountOptionPasswordInput" v-model="providerConfig.mountOptionPassword" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -156,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>
|
||||
@@ -177,19 +187,19 @@ onMounted(async () => {
|
||||
<!-- SSHFS -->
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
|
||||
<textarea id="mountOptionPrivateKeyInput" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
<textarea id="mountOptionPrivateKeyInput" rows="7" style="white-space: nowrap;" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
|
||||
</FormGroup>
|
||||
|
||||
<!-- 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')"/>
|
||||
@@ -199,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' ||
|
||||
@@ -235,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)">
|
||||
@@ -244,15 +257,16 @@ onMounted(async () => {
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="accessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
|
||||
<TextInput id="accessKeyInput" v-model="providerConfig.secretAccessKey" required />
|
||||
<label for="secretAccessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
|
||||
<TextInput id="secretAccessKeyInput" v-model="providerConfig.secretAccessKey" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'gcs'">
|
||||
<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>
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Checkbox, Dialog, FormGroup, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
|
||||
const id = ref('');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const scheduleEnabled = ref(false);
|
||||
const days = ref([]);
|
||||
const hours = ref([]);
|
||||
const configureRetention = ref('');
|
||||
const isConfigureValid = computed(() => {
|
||||
return !!days.value.length && !!hours.value.length;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isConfigureValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
|
||||
let schedule;
|
||||
if (scheduleEnabled.value) {
|
||||
let daysPattern;
|
||||
if (days.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = days.value;
|
||||
|
||||
let hoursPattern;
|
||||
if (hours.value.length === 24) hoursPattern = '*';
|
||||
else hoursPattern = hours.value;
|
||||
|
||||
schedule = `00 00 ${hoursPattern} * * ${daysPattern}`;
|
||||
} else {
|
||||
schedule = 'never';
|
||||
}
|
||||
|
||||
let [error] = await backupSitesModel.setSchedule(id.value, schedule);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
[error] = await backupSitesModel.setRetention(id.value, configureRetention.value);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(site) {
|
||||
id.value = site.id;
|
||||
busy.value = false;
|
||||
formError.value = false;
|
||||
|
||||
const currentRetentionString = JSON.stringify(site.retention);
|
||||
let selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
|
||||
if (!selectedRetention) selectedRetention = BackupSitesModel.backupRetentions[0];
|
||||
configureRetention.value = selectedRetention.id;
|
||||
|
||||
if (site.schedule === 'never') {
|
||||
scheduleEnabled.value = false;
|
||||
} else {
|
||||
scheduleEnabled.value = true;
|
||||
|
||||
const tmp = site.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
const tmpDays = tmp[5].split(',');
|
||||
|
||||
if (tmpDays[0] === '*') days.value = BackupSitesModel.cronDays.map((day) => { return day.id; });
|
||||
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
|
||||
|
||||
if (tmpHours[0] === '*') hours.value = BackupSitesModel.cronHours.map(h => h.id);
|
||||
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.configureBackupSchedule.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="isConfigureValid"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div class="error-label" v-show="formError">{{ formError }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
|
||||
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')" style="margin-bottom: 10px; "></div>
|
||||
|
||||
<Checkbox :label="$t('main.statusEnabled')" v-model="scheduleEnabled" />
|
||||
<div v-if="scheduleEnabled" style="display: flex; gap: 10px; margin-left: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" :disabled="!scheduleEnabled" v-model="days" :options="BackupSitesModel.cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="BackupSitesModel.cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="retentionInput">{{ $t('backups.configureBackupSchedule.retentionPolicy') }}</label>
|
||||
<select id="retentionInput" v-model="configureRetention">
|
||||
<option v-for="elem in BackupSitesModel.backupRetentions" :key="elem.id" :value="elem.id">{{ elem.name }}</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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('');
|
||||
@@ -29,7 +28,7 @@ const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const enableForUpdates = ref(false);
|
||||
const provider = ref('');
|
||||
const includeExclude = ref('everything'); // or exclude, include
|
||||
const includeExclude = ref(''); // or exclude, include
|
||||
const contentOptions = ref([]);
|
||||
const contentInclude = ref([]);
|
||||
const contentExclude = 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,6 +229,12 @@ function onCancel() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open() {
|
||||
step.value = 'storage';
|
||||
@@ -237,14 +245,17 @@ defineExpose({
|
||||
enableForUpdates.value = false;
|
||||
provider.value = '';
|
||||
format.value = '';
|
||||
providerConfig.value = {};
|
||||
providerConfig.value = {
|
||||
mountOptions: {},
|
||||
prefix: '',
|
||||
};
|
||||
useEncryption.value = false;
|
||||
encryptionPassword.value = '';
|
||||
encryptionPasswordRepeat.value = '';
|
||||
encryptionPasswordHint.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
limits.value = {};
|
||||
includeExclude.value = 'everything';
|
||||
includeExclude.value = '';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
@@ -279,6 +290,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -288,7 +301,7 @@ defineExpose({
|
||||
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
|
||||
<div>
|
||||
<div v-if="step === 'storage'">
|
||||
<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"/>
|
||||
|
||||
@@ -301,19 +314,19 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</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')"/>
|
||||
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')" required/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')" required/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :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')"/>
|
||||
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')" required/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
|
||||
<div description>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
|
||||
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
|
||||
</FormGroup>
|
||||
|
||||
@@ -363,11 +376,11 @@ defineExpose({
|
||||
-->
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: end; margin-top: 10px">
|
||||
<Checkbox v-model="useEncryption" label="Encrypt Backups"/>
|
||||
<Checkbox v-model="useEncryption" :label="$t('backups.configureBackupStorage.useEncryption')"/>
|
||||
|
||||
<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" :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>
|
||||
@@ -379,11 +392,11 @@ defineExpose({
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<div class="warning-label">{{ $t('backups.configureBackupStorage.encryptionDescription') }}</div>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<FormGroup>
|
||||
<label for="encryptionPassswordInput">{{ $t('backups.configureBackupStorage.encryptionPassword') }} <sup><a href="https://docs.cloudron.io/backups/#encryption" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<PasswordInput id="encryptionPassswordInput" v-model="encryptionPassword" :placeholder="$t('backups.configureBackupStorage.encryptionPasswordPlaceholder')" required />
|
||||
<div class="warning-label">{{ $t('backups.configureBackupStorage.encryptionDescription') }}</div>
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
<FormGroup>
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const minMemoryLimit = ref(1024 * 1024 * 1024); // 1 GB
|
||||
const maxMemoryLimit = ref(minMemoryLimit.value); // set later
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const site = ref({});
|
||||
const provider = ref('');
|
||||
const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const name = ref('');
|
||||
const enableForUpdates = ref(false);
|
||||
const memoryLimit = ref(0);
|
||||
const uploadPartSize = ref(0);
|
||||
const syncConcurrency = ref(0);
|
||||
const downloadConcurrency = ref(0);
|
||||
const copyConcurrency = ref(0);
|
||||
|
||||
const accessKeyId = ref('');
|
||||
const secretAccessKey = ref('');
|
||||
const mountOptionsUsername = ref('');
|
||||
const mountOptionsPassword = ref('');
|
||||
const mountOptionsUser = ref('');
|
||||
const mountOptionsPrivateKey = ref('');
|
||||
const useHardlinks = ref(false);
|
||||
const chown = ref(false);
|
||||
const preserveAttributes = ref(false);
|
||||
|
||||
watch(mountOptionsPrivateKey, () => {
|
||||
if (!mountOptionsPrivateKey.value) return;
|
||||
mountOptionsPrivateKey.value = mountOptionsPrivateKey.value.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
if (site.value.name !== name.value) {
|
||||
const [error] = await backupSitesModel.setName(site.value.id, name.value);
|
||||
if (error) {
|
||||
if (error.status === 400) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
const data = site.value.config;
|
||||
|
||||
// TODO maybe deal with gcs??
|
||||
if (s3like(provider.value)) {
|
||||
data.accessKeyId = accessKeyId.value;
|
||||
if (secretAccessKey.value) data.secretAccessKey = secretAccessKey.value;
|
||||
}
|
||||
|
||||
if (provider.value === 'cifs') {
|
||||
data.mountOptions.username = mountOptionsUsername.value;
|
||||
if (mountOptionsPassword.value) data.mountOptions.password = mountOptionsPassword.value;
|
||||
}
|
||||
|
||||
if (provider.value === 'sshfs') {
|
||||
data.mountOptions.user = mountOptionsUser.value;
|
||||
if (mountOptionsPrivateKey.value) data.mountOptions.privateKey = mountOptionsPrivateKey.value;
|
||||
}
|
||||
|
||||
if ((provider.value === 'filesystem' || mountlike(provider.value)) && site.value.format === 'rsync') {
|
||||
data.noHardlinks = !useHardlinks.value;
|
||||
}
|
||||
|
||||
if ((provider.value === 'mountpoint' || provider.value === 'cifs') && site.value.format === 'rsync') {
|
||||
data.preserveAttributes = preserveAttributes.value;
|
||||
}
|
||||
|
||||
if (provider.value === 'mountpoint' && site.value.format === 'rsync') {
|
||||
data.chown = chown.value;
|
||||
}
|
||||
|
||||
// only call if anything has changed
|
||||
if (Object.keys(data).length) {
|
||||
const [error] = await backupSitesModel.setConfig(site.value.id, data);
|
||||
if (error) {
|
||||
if (error.status === 400) {
|
||||
// if (error.body.message.indexOf('password') === 0) formError.value.generic = 'Username or password is wrong';
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
} else {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const limits = {
|
||||
memoryLimit: parseInt(memoryLimit.value),
|
||||
uploadPartSize: parseInt(uploadPartSize.value),
|
||||
syncConcurrency: parseInt(syncConcurrency.value),
|
||||
downloadConcurrency: parseInt(downloadConcurrency.value),
|
||||
copyConcurrency: parseInt(copyConcurrency.value),
|
||||
};
|
||||
|
||||
const [error] = await backupSitesModel.setLimits(site.value.id, limits);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function getMemory() {
|
||||
const [error, result] = await systemModel.memory();
|
||||
if (error) return console.error(error);
|
||||
|
||||
maxMemoryLimit.value = Math.ceil(result.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(t) {
|
||||
t = JSON.parse(JSON.stringify(t)); // make a copy
|
||||
formError.value = {};
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
|
||||
name.value = t.name || '';
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
memoryLimit.value = t.limits.memoryLimit || 1024 * 1024 * 1024; // 1 GB
|
||||
uploadPartSize.value = t.limits.uploadPartSize || 10 * 1024 * 1024;
|
||||
syncConcurrency.value = t.limits.syncConcurrency || 10;
|
||||
downloadConcurrency.value = t.limits.downloadConcurrency || 10;
|
||||
copyConcurrency.value = t.limits.copyConcurrency || 10;
|
||||
|
||||
if (s3like(provider.value)) {
|
||||
accessKeyId.value = t.config.accessKeyId;
|
||||
secretAccessKey.value = null;
|
||||
} else if (provider.value === 'cifs') {
|
||||
mountOptionsUsername.value = t.config.mountOptions.username;
|
||||
mountOptionsPassword.value = null;
|
||||
} else if (provider.value === 'sshfs') {
|
||||
mountOptionsUser.value = t.config.mountOptions.user;
|
||||
mountOptionsPrivateKey.value = null;
|
||||
}
|
||||
|
||||
if ((provider.value === 'filesystem' || mountlike(provider.value)) && site.value.format === 'rsync') {
|
||||
useHardlinks.value = !t.config.noHardlinks;
|
||||
}
|
||||
|
||||
if ((provider.value === 'mountpoint' || provider.value === 'cifs') && site.value.format === 'rsync') {
|
||||
preserveAttributes.value = !!t.config.preserveAttributes;
|
||||
}
|
||||
|
||||
if (provider.value === 'mountpoint' && site.value.format === 'rsync') {
|
||||
chown.value = !!t.config.chown;
|
||||
}
|
||||
|
||||
await getMemory();
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.configureBackupStorage.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="backupSiteNameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
|
||||
<TextInput id="backupSiteNameInput" v-model="name" required/>
|
||||
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<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>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionsUserInput">{{ $t('backups.configureBackupStorage.user') }}</label>
|
||||
<TextInput id="mountOptionsUserInput" v-model="mountOptionsUser" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
<label for="mountOptionsPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
|
||||
<MaskedInput id="mountOptionsPrivateKeyInput" :multiline="true" v-model="mountOptionsPrivateKey" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionsUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
|
||||
<TextInput id="mountOptionsUsernameInput" v-model="mountOptionsUsername" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'cifs'">
|
||||
<label for="mountOptionsPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
|
||||
<MaskedInput id="mountOptionsPasswordInput" v-model="mountOptionsPassword" required />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="(provider === 'filesystem' || mountlike(provider)) && site.format === 'rsync'" v-model="useHardlinks" :label="$t('backups.configureBackupStorage.hardlinksLabel')"/>
|
||||
|
||||
<Checkbox v-if="(provider === 'mountpoint' || provider === 'cifs') && site.format === 'rsync'" v-model="preserveAttributes" :label="$t('backups.configureBackupStorage.preserveAttributesLabel')"/>
|
||||
|
||||
<Checkbox v-if="provider === 'mountpoint' && format === 'rsync'" v-model="chown" :label="$t('backups.configureBackupStorage.chown')"/>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="accessKeyIdInput">{{ $t('backups.configureBackupStorage.s3AccessKeyId') }}</label>
|
||||
<TextInput id="accessKeyIdInput" v-model="accessKeyId" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="secretAccessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
|
||||
<MaskedInput id="secretAccessKeyInput" v-model="secretAccessKey" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</div>
|
||||
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
|
||||
<datalist id="uploadPartSizeTicks">
|
||||
<option :value="1024*1024*10"></option>
|
||||
<option :value="1024*1024*64"></option>
|
||||
<option :value="1024*1024*128"></option>
|
||||
<option :value="1024*1024*256"></option>
|
||||
<option :value="1024*1024*512"></option>
|
||||
<option :value="1024*1024*1024"></option>
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync'">
|
||||
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
|
||||
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
|
||||
<div description class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}</div>
|
||||
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Checkbox, Radiobutton, MultiSelect, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const site = ref({});
|
||||
const provider = ref('');
|
||||
const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const enableForUpdates = ref(false);
|
||||
const includeExclude = ref('everything'); // or include, exclude
|
||||
const contentOptions = ref([]);
|
||||
const contentInclude = ref([]);
|
||||
const contentExclude = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let [error] = await backupSitesModel.setEnableForUpdates(site.value.id, enableForUpdates.value);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
let contents;
|
||||
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') {
|
||||
if (contentInclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Include at least one content item';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
[error] = await backupSitesModel.setContents(site.value.id, contents);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(t) {
|
||||
t = JSON.parse(JSON.stringify(t)); // make a copy
|
||||
|
||||
formError.value = {};
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
includeExclude.value = 'everything';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'System & email',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
contentOptions.value.push({
|
||||
id: a.id,
|
||||
label: `${a.label || a.fqdn} - ${a.manifest.title}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (t.contents !== null) {
|
||||
if (t.contents.exclude) {
|
||||
includeExclude.value = 'exclude';
|
||||
contentExclude.value = t.contents.exclude;
|
||||
} else if (t.contents.include) {
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.configureContent.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<p>{{ $t('backups.configureBackupStorage.backupContents.context', { name: site.name }) }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<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'" 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'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
|
||||
<div description>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
|
||||
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,229 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Checkbox, Radiobutton, MultiSelect, Dialog, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like } from '../utils.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const minMemoryLimit = ref(1024 * 1024 * 1024); // 1 GB
|
||||
const maxMemoryLimit = ref(minMemoryLimit.value); // set later
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const site = ref({});
|
||||
const formError = ref({});
|
||||
const busy = ref(false);
|
||||
const name = ref('');
|
||||
const enableForUpdates = ref(false);
|
||||
const memoryLimit = ref(0);
|
||||
const uploadPartSize = ref(0);
|
||||
const syncConcurrency = ref(0);
|
||||
const downloadConcurrency = ref(0);
|
||||
const copyConcurrency = ref(0);
|
||||
const includeExclude = ref('everything'); // or include, exclude
|
||||
const contentOptions = ref([]);
|
||||
const contentInclude = ref([]);
|
||||
const contentExclude = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let [error] = await backupSitesModel.setName(site.value.id, name.value);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
[error] = await backupSitesModel.setEnableForUpdates(site.value.id, enableForUpdates.value);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
let contents;
|
||||
if (includeExclude.value === 'everything') {
|
||||
contents = null;
|
||||
} else if (includeExclude.value === 'exclude') {
|
||||
contents = { exclude: contentExclude.value };
|
||||
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
[error] = await backupSitesModel.setContents(site.value.id, contents);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
const limits = {
|
||||
memoryLimit: parseInt(memoryLimit.value),
|
||||
uploadPartSize: parseInt(uploadPartSize.value),
|
||||
syncConcurrency: parseInt(syncConcurrency.value),
|
||||
downloadConcurrency: parseInt(downloadConcurrency.value),
|
||||
copyConcurrency: parseInt(copyConcurrency.value),
|
||||
};
|
||||
|
||||
[error] = await backupSitesModel.setLimits(site.value.id, limits);
|
||||
if (error) {
|
||||
formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
async function getMemory() {
|
||||
const [error, result] = await systemModel.memory();
|
||||
if (error) return console.error(error);
|
||||
|
||||
maxMemoryLimit.value = Math.ceil(result.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(t) {
|
||||
formError.value = {};
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
|
||||
name.value = t.name || '';
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
memoryLimit.value = t.limits.memoryLimit || 1024 * 1024 * 1024; // 1 GB
|
||||
uploadPartSize.value = t.limits.uploadPartSize || 10 * 1024 * 1024;
|
||||
syncConcurrency.value = t.limits.syncConcurrency || 10;
|
||||
downloadConcurrency.value = t.limits.downloadConcurrency || 10;
|
||||
copyConcurrency.value = t.limits.copyConcurrency || 10;
|
||||
|
||||
await getMemory();
|
||||
|
||||
const [error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'Platform',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
contentOptions.value.push({
|
||||
id: a.id,
|
||||
label: `${a.label || a.fqdn} - ${a.manifest.title}`,
|
||||
});
|
||||
});
|
||||
|
||||
if (t.contents !== null) {
|
||||
if (t.contents.exclude) {
|
||||
includeExclude.value = 'exclude';
|
||||
contentExclude.value = t.contents.exclude;
|
||||
} else if (t.contents.include) {
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
} else {
|
||||
includeExclude.value = 'everything';
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.configureBackupStorage.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
confirm-style="primary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="backupSiteNameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
|
||||
<TextInput id="backupSiteNameInput" v-model="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</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;"/>
|
||||
<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;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
|
||||
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(site.provider)">
|
||||
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
|
||||
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
|
||||
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
|
||||
<datalist id="uploadPartSizeTicks">
|
||||
<option :value="1024*1024*10"></option>
|
||||
<option :value="1024*1024*64"></option>
|
||||
<option :value="1024*1024*128"></option>
|
||||
<option :value="1024*1024*256"></option>
|
||||
<option :value="1024*1024*512"></option>
|
||||
<option :value="1024*1024*1024"></option>
|
||||
</datalist>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync'">
|
||||
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(site.provider) || site.provider === 'gcs')">
|
||||
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="site.format === 'rsync' && (s3like(site.provider) || site.provider === 'gcs')">
|
||||
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
|
||||
<span v-show="site.provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
|
||||
</div>
|
||||
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,130 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import { cronDays, cronHours, parseSchedule } from '../utils.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
|
||||
const site = ref({});
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const scheduleType = ref('');
|
||||
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 scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isConfigureValid.value) return;
|
||||
|
||||
busy.value = true;
|
||||
|
||||
let schedule;
|
||||
if (scheduleType.value === 'pattern') {
|
||||
let daysPattern;
|
||||
if (days.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = days.value;
|
||||
|
||||
let hoursPattern;
|
||||
if (hours.value.length === 24) hoursPattern = '*';
|
||||
else hoursPattern = hours.value;
|
||||
|
||||
schedule = `00 00 ${hoursPattern} * * ${daysPattern}`;
|
||||
} else {
|
||||
schedule = 'never';
|
||||
}
|
||||
|
||||
let [error] = await backupSitesModel.setSchedule(site.value.id, schedule);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return x.name === configureRetention.value; });
|
||||
[error] = await backupSitesModel.setRetention(site.value.id, selectedRetention.id);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
emit('success');
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(s) {
|
||||
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; });
|
||||
configureRetention.value = selectedRetention ? selectedRetention.name : BackupSitesModel.backupRetentions[0].name;
|
||||
|
||||
if (site.value.schedule === 'never') {
|
||||
scheduleType.value = 'never';
|
||||
} else {
|
||||
scheduleType.value = 'pattern';
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.configureBackupSchedule.title')"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="isConfigureValid"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p>{{ $t('backups.configureBackupSchedule.schedule.context', { name: site.name }) }}</p>
|
||||
|
||||
<div class="error-label" v-show="formError">{{ formError }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
<FormGroup>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule.title') }}</label>
|
||||
<div description v-html="$t('backups.configureBackupSchedule.schedule.description')"></div>
|
||||
|
||||
<Radiobutton v-model="scheduleType" value="never" :label="$t('backups.configureBackupSchedule.disable')"/>
|
||||
<Radiobutton v-model="scheduleType" value="pattern" :label="$t('backups.configureBackupSchedule.enable')"/>
|
||||
<div v-if="scheduleType === 'pattern'" style="display: flex; align-items: center; gap: 10px; margin: 10px">
|
||||
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div class="text-small text-danger" v-show="scheduleType === 'pattern' && !(hours.length !== 0 && days.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="retentionInput">{{ $t('backups.configureBackupSchedule.retentionPolicy') }}</label>
|
||||
<!-- we do not used id as key because SingleSelect can only handle strings -->
|
||||
<SingleSelect id="retentionInput" v-model="configureRetention" :options="BackupSitesModel.backupRetentions" option-key="name" option-label="name" />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, inject } from 'vue';
|
||||
import { API_ORIGIN } from '../constants.js';
|
||||
import Section from './Section.vue';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
import EditableField from './EditableField.vue';
|
||||
import ImagePicker from './ImagePicker.vue';
|
||||
import BrandingModel from '../models/BrandingModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
|
||||
const brandingModel = BrandingModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
const avatarUrl = ref(`${API_ORIGIN}/api/v1/cloudron/avatar?${String(Math.random()).slice(2)}`);
|
||||
const backgroundUrl = ref(`${API_ORIGIN}/api/v1/cloudron/background?${String(Math.random()).slice(2)}`);
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
const name = ref('');
|
||||
const savingName = ref(false);
|
||||
|
||||
async function onNameSave(newName) {
|
||||
savingName.value = true;
|
||||
|
||||
const [error] = await brandingModel.setName(newName);
|
||||
savingName.value = false;
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
name.value = newName;
|
||||
}
|
||||
|
||||
async function onAvatarSubmit(file) {
|
||||
const [error] = await brandingModel.setAvatar(file);
|
||||
if (error) return console.error(error);
|
||||
}
|
||||
|
||||
async function onBackgroundSubmit(file) {
|
||||
const [error] = await brandingModel.setBackground(file);
|
||||
if (error) return console.error(error);
|
||||
}
|
||||
|
||||
async function onBackgroundUnset() {
|
||||
const [error] = await brandingModel.unsetBackground();
|
||||
if (error) return console.error(error);
|
||||
backgroundUrl.value = '';
|
||||
}
|
||||
|
||||
// Footer
|
||||
const footer = ref('');
|
||||
const savingFooter = ref(false);
|
||||
|
||||
async function onFooterSave(newFooter) {
|
||||
savingFooter.value = true;
|
||||
|
||||
const [error] = await brandingModel.setFooter(newFooter);
|
||||
savingFooter.value = false;
|
||||
if (error) return console.error(error);
|
||||
|
||||
footer.value = newFooter;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
name.value = result.cloudronName;
|
||||
|
||||
[error, result] = await brandingModel.getFooter();
|
||||
if (error) return console.error(error);
|
||||
footer.value = result;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section :title="$t('branding.title')" :title-badge="!features.branding ? 'Upgrade' : ''">
|
||||
<div style="display: flex; justify-content: space-around; margin-bottom: 20px;">
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
<label>{{ $t('branding.logo') }}</label>
|
||||
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding" fallback-src="/api/v1/cloudron/avatar"/>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
|
||||
<label>{{ $t('branding.backgroundImage') }}</label>
|
||||
<ImagePicker mode="editable" :src="backgroundUrl" :save-handler="onBackgroundSubmit" :unset-handler="onBackgroundUnset" :disabled="!features.branding" fallback-src="/img/background-image-placeholder.svg" display-height="128px" :max-size="1280"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.footer.title')" helpUrl="https://docs.cloudron.io/branding/#footer" multiline markdown :saving="savingFooter" :value="footer" :disabled="!features.branding" @save="onFooterSave"/>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -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';
|
||||
@@ -8,12 +12,15 @@ import Section from '../components/Section.vue';
|
||||
import SettingsItem from '../components/SettingsItem.vue';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
|
||||
const taskModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const props = defineProps([ 'domains' ]);
|
||||
const inputDialog = inject('inputDialog');
|
||||
|
||||
const domains = ref([]);
|
||||
const formError = ref('');
|
||||
const originalDomain = ref('');
|
||||
const newDomain = ref('');
|
||||
@@ -63,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;
|
||||
@@ -81,16 +98,32 @@ async function onSubmit() {
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function selectCurrentDomain(newDomains) {
|
||||
domains.value = newDomains;
|
||||
|
||||
const [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
|
||||
newDomain.value = (props.domains.find(d => d.domain === result.adminDomain) || props.domains[0]).domain;
|
||||
originalDomain.value = (props.domains.find(d => d.domain === result.adminDomain) || props.domains[0]).domain;
|
||||
if (domains.value.length) {
|
||||
const adminDomain = domains.value.find(d => d.domain === result.adminDomain);
|
||||
if (adminDomain) {
|
||||
newDomain.value = adminDomain.domain;
|
||||
originalDomain.value = adminDomain.domain;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
await selectCurrentDomain(result);
|
||||
|
||||
await refreshTasks();
|
||||
});
|
||||
|
||||
defineExpose({ updateDomains: selectCurrentDomain });
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -113,7 +146,7 @@ onMounted(async () => {
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
<div v-if="lastTask.active" style="padding: 0 10px">
|
||||
<div v-if="lastTask.active">
|
||||
<ProgressBar :value="lastTask.percent" :busy="true" />
|
||||
<div>{{ lastTask.message }}</div>
|
||||
</div>
|
||||
|
||||
+30
-17
@@ -1,8 +1,8 @@
|
||||
<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';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -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>
|
||||
@@ -22,7 +22,7 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('system.diskUsage.title')">
|
||||
<div class="filesystems-grid">
|
||||
<div>
|
||||
<DiskUsageItem v-for="filesystem in filesystems" :key="filesystem.filesystem" :filesystem="filesystem" />
|
||||
</div>
|
||||
</Section>
|
||||
@@ -30,16 +30,6 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.filesystems-grid {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: 300ms;
|
||||
flex-wrap: wrap;
|
||||
justify-content: start;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.usage-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -85,10 +75,4 @@ onMounted(async () => {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disks-last-updated {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,60 +1,32 @@
|
||||
<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 AppsModel from '../models/AppsModel.js';
|
||||
import VolumesModel from '../models/VolumesModel.js';
|
||||
import { prettyDecimalSize, prettyDate } from '@cloudron/pankow/utils';
|
||||
import { getColor } from '../utils.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const volumesModel = VolumesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const props = defineProps({
|
||||
filesystem: Object
|
||||
});
|
||||
|
||||
function hue(numOfSteps, step) {
|
||||
const deg = 360/numOfSteps;
|
||||
return `hsl(${deg*step} 70% 50%)`;
|
||||
}
|
||||
|
||||
let colorIndex = 0;
|
||||
let colors = [];
|
||||
function resetColors(n) {
|
||||
colorIndex = 7;
|
||||
colors = [];
|
||||
for (let i = 0; i < n; i++) colors.push(hue(n, i));
|
||||
}
|
||||
|
||||
function getNextColor() {
|
||||
return colors[colorIndex++];
|
||||
}
|
||||
|
||||
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() {
|
||||
let [error, result] = await appsModel.list();
|
||||
async function getUsage() {
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
const appsById = {};
|
||||
result.forEach(a => { appsById[a.id] = a; });
|
||||
|
||||
[error, result] = await volumesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const volumesById = {};
|
||||
result.forEach(v => { volumesById[v.id] = v; });
|
||||
|
||||
[error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value = [];
|
||||
|
||||
@@ -65,23 +37,16 @@ async function refresh() {
|
||||
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
|
||||
// we first 8 colors are reserved for known system contents
|
||||
resetColors(contents.value.length + 8);
|
||||
contents.value.forEach(content => {
|
||||
// assign fixed colors for known entries
|
||||
if (content.id === 'platformdata') content.color = colors[0];
|
||||
else if (content.id === 'boxdata') content.color = colors[1];
|
||||
else if (content.id === 'maildata') content.color = colors[2];
|
||||
else if (content.id === 'cloudron-backup-default') content.color = colors[3];
|
||||
else if (content.id === 'docker') content.color = colors[4];
|
||||
else if (content.id === 'docker-volumes') content.color = colors[5];
|
||||
else if (content.id === '/apps.swap') content.color = colors[6];
|
||||
else if (content.id === 'os') content.color = colors[7];
|
||||
else content.color = getNextColor();
|
||||
});
|
||||
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;
|
||||
@@ -89,16 +54,8 @@ async function refresh() {
|
||||
if (payload.speed) {
|
||||
speed.value = payload.speed;
|
||||
} else if (payload.content) {
|
||||
if (payload.content.type === 'app') {
|
||||
payload.content.app = appsById[payload.content.id];
|
||||
if (!payload.content.app) payload.content.uninstalled = true;
|
||||
else payload.content.label = payload.content.app.label || payload.content.app.fqdn;
|
||||
} else if (payload.content.type === 'volume') {
|
||||
payload.content.volume = volumesById[payload.content.id];
|
||||
payload.content.label = payload.content.volume ? `Volume ${payload.content.volume.name}` : 'Removed volume';
|
||||
} else {
|
||||
payload.content.label = payload.content.id;
|
||||
}
|
||||
// this can happen if more than one backup sites for filesystem share the folder, so avoid negativ values here
|
||||
if (payload.content.usage < 0) payload.content.usage = 0;
|
||||
contents.value.push(payload.content);
|
||||
} else {
|
||||
console.error('Unkown data', payload);
|
||||
@@ -117,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();
|
||||
});
|
||||
@@ -130,32 +111,38 @@ 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">
|
||||
<ProgressBar v-if="percent < 100" mode="indeterminate" :show-label="false"/>
|
||||
<div v-else class="disk-size" style="overflow: visible;">
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.id" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.name" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="percent < 100" style="text-align: center;">Calculating speed and disk usage ... {{ percent }}%</div>
|
||||
<div v-if="percent < 100" style="text-align: center; margin-top: 10px;">Calculating speed and disk usage ... {{ parseInt(percent) }}%</div>
|
||||
<div v-else>
|
||||
<table style="width: 100%">
|
||||
<table style="width: 100%;table-layout: fixed">
|
||||
<tr v-for="content in contents" :key="content.id" @mouseover="highlight = content.id" :class="{ highlight: highlight === content.id }">
|
||||
<td style="width: 20px"><div class="content-color-indicator" :style="{ backgroundColor: content.color }"></div></td>
|
||||
<td>{{ content.label }}</td>
|
||||
<td style="text-align: right">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
|
||||
<a v-if="content.type === 'app'" :href="`/#/app/${content.id}/info`">{{ content.name }}</a>
|
||||
<a v-else-if="content.type === 'volume'" href="/#/volumes">{{ content.name }} (Volume)</a>
|
||||
<span v-else>{{ content.name }}</span>
|
||||
</td>
|
||||
<td style="text-align: right; white-space: nowrap;">{{ prettyDecimalSize(content.usage) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<ProgressBar :value="parseInt(filesystem.capacity*100)" :show-label="false"/>
|
||||
<div style="text-align: center">
|
||||
<div style="text-align: center; margin-top: 10px;">
|
||||
<Button plain @click="onExpand()">Details</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,12 +156,11 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
margin: 10px;
|
||||
max-width: 620px;
|
||||
padding: 10px 16px;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background-color: var(--card-background);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disk-item:focus,
|
||||
@@ -191,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;
|
||||
}
|
||||
|
||||
@@ -239,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');
|
||||
@@ -62,11 +61,12 @@ function onEditOrAdd(registry = null) {
|
||||
|
||||
async function onRemove(registry) {
|
||||
const yes = await inputDialog.value.confirm({
|
||||
title: t('dockerRegistries.removeDialog.title', { serverAddress: registry.serverAddress}),
|
||||
message: t('dockerRegistres.removeDialog.description'),
|
||||
title: t('dockerRegistries.removeDialog.title'),
|
||||
message: t('dockerRegistres.removeDialog.description', { serverAddress: registry.serverAddress }),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.delete'),
|
||||
rejectLabel: t('main.dialog.cancel')
|
||||
confirmLabel: t('main.action.remove'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!yes) return;
|
||||
@@ -93,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()"/>
|
||||
|
||||
@@ -105,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>
|
||||
|
||||
@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import { isValidDomainOrURL } from '@cloudron/pankow/utils';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, SingleSelect, MaskedInput } from '@cloudron/pankow';
|
||||
import DockerRegistriesModel from '../models/DockerRegistriesModel.js';
|
||||
|
||||
const dockerRegistriesModel = DockerRegistriesModel.create();
|
||||
@@ -19,7 +18,6 @@ const providers = [
|
||||
{ name: 'Google Cloud', value: 'google-cloud' },
|
||||
{ name: 'Linode', value: 'linode' },
|
||||
{ name: 'Quay', value: 'quay' },
|
||||
{ name: 'Treescale', value: 'treescale' },
|
||||
{ name: t('settings.registryConfig.providerOther') || 'Other', value: 'other' },
|
||||
];
|
||||
|
||||
@@ -36,17 +34,15 @@ const username = ref('');
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!provider.value) return false;
|
||||
if (!serverAddress.value) return false;
|
||||
if (!username.value) return false;
|
||||
if (!password.value) return false;
|
||||
if (!isValidDomainOrURL(serverAddress.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 (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
|
||||
@@ -66,6 +62,7 @@ async function onSubmit() {
|
||||
|
||||
defineExpose({
|
||||
async open(r = null) {
|
||||
r = r ? JSON.parse(JSON.stringify(r)) : null; // make a copy
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
registry.value = r;
|
||||
@@ -76,6 +73,8 @@ defineExpose({
|
||||
password.value = r ? r.password : '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -83,23 +82,23 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('dockerRegistries.dialog.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:title="registry ? $t('dockerRegistries.dialog.editTitle') : $t('dockerRegistries.dialog.addTitle')"
|
||||
:confirm-label="registry ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isValid"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<input style="display: none" type="submit" :disabled="busy"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('settings.registryConfig.provider') }} <sup><a href="https://docs.cloudron.io/settings/#private-docker-registry" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="providers" option-key="value" option-label="name" />
|
||||
<SingleSelect id="providerInput" v-model="provider" :options="providers" option-key="value" option-label="name" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
@@ -113,13 +112,13 @@ defineExpose({
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="emailInput">{{ $t('dockerRegistries.email') }} (Optional)</label>
|
||||
<label for="emailInput">{{ $t('dockerRegistries.email') }} (optional)</label>
|
||||
<TextInput id="emailInput" v-model="email" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('dockerRegistries.passwordToken') }}</label>
|
||||
<TextInput id="passwordInput" v-model="password" required />
|
||||
<MaskedInput id="passwordInput" v-model="password" required />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox } from '@cloudron/pankow';
|
||||
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';
|
||||
import DomainProviderForm from './DomainProviderForm.vue';
|
||||
|
||||
@@ -10,6 +11,8 @@ const emit = defineEmits([ 'success' ]);
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const keyFileInput = useTemplateRef('keyFileInput');
|
||||
const certificateFileInput = useTemplateRef('certificateFileInput');
|
||||
|
||||
const busy = ref(false);
|
||||
const errorMessage = ref('');
|
||||
@@ -20,15 +23,17 @@ const provider = ref('');
|
||||
const tlsProvider = ref('letsencrypt-prod-wildcard');
|
||||
const showAdvanced = ref(false);
|
||||
const customNameservers = ref(false);
|
||||
const certificateFileName = ref('');
|
||||
const keyFileName = ref('');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
@@ -49,8 +54,27 @@ async function onSubmit() {
|
||||
tlsConfig.wildcard = true;
|
||||
}
|
||||
|
||||
let fallbackCertificate = null;
|
||||
if (tlsConfig.provider === 'fallback') {
|
||||
const certFile = certificateFileInput.value.files[0] || null;
|
||||
const keyFile = keyFileInput.value.files[0] || null;
|
||||
|
||||
if ((!certFile && keyFile) || (certFile && !keyFile)) {
|
||||
errorMessage.value = 'Both certificate and key file need to be provided';
|
||||
busy.value = false;
|
||||
return;
|
||||
} else if (certFile && keyFile) {
|
||||
fallbackCertificate = {
|
||||
cert: await getTextFromFile(certFile),
|
||||
key: await getTextFromFile(keyFile),
|
||||
};
|
||||
} else {
|
||||
// unchanged
|
||||
}
|
||||
}
|
||||
|
||||
const func = editing.value ? domainsModel.update : domainsModel.add;
|
||||
const [error] = await func(domain.value, zoneName.value, provider.value, config, null, tlsConfig);
|
||||
const [error] = await func(domain.value, zoneName.value, provider.value, config, fallbackCertificate, tlsConfig);
|
||||
if (error) {
|
||||
errorMessage.value = error.body ? error.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
@@ -61,9 +85,27 @@ async function onSubmit() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
function onCertificateFileChange() {
|
||||
const file = certificateFileInput.value.files[0] || '';
|
||||
if (!file) return;
|
||||
|
||||
certificateFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
function onKeyFileChange() {
|
||||
const file = keyFileInput.value.files[0] || '';
|
||||
if (!file) return;
|
||||
|
||||
keyFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open(d) {
|
||||
d = d || { config: {}, tlsConfig: {}};
|
||||
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
|
||||
|
||||
provider.value = d.provider || '';
|
||||
dnsConfig.value = d.config;
|
||||
@@ -79,13 +121,13 @@ defineExpose({
|
||||
editing.value = !!d.domain;
|
||||
domain.value = d.domain || '';
|
||||
zoneName.value = d.zoneName || '';
|
||||
|
||||
customNameservers.value = d.config.customNameservers;
|
||||
certificateFileName.value = d.tlsConfig?.provider === 'fallback' ? String.fromCharCode(0x25CF).repeat(8) : '';
|
||||
keyFileName.value = d.tlsConfig?.provider === 'fallback' ? String.fromCharCode(0x25CF).repeat(8) : '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
// ensure we trigger this once
|
||||
setTimeout(checkValidity, 100);
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -93,11 +135,12 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle', { domain: domain }) : $t('domains.domainDialog.addTitle')"
|
||||
:title="editing ? $t('domains.domainDialog.editTitle') : $t('domains.domainDialog.addTitle')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:reject-label="busy ? null : $t('main.dialog.cancel')"
|
||||
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
@@ -109,42 +152,37 @@ 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" :domain="domain" :show-advanced="showAdvanced" />
|
||||
|
||||
<p style="margin-top: 15px" v-show="!showAdvanced" @click="showAdvanced = true" class="actionable">{{ $t('domains.domainDialog.advancedAction') }}</p>
|
||||
<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" />
|
||||
|
||||
<div v-show="showAdvanced">
|
||||
<FormGroup>
|
||||
<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" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
|
||||
|
||||
<!-- custom certificate -->
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<label >{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
|
||||
<p v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></p>
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
|
||||
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
|
||||
</div>
|
||||
|
||||
<FormGroup v-if="tlsProvider === 'fallback'">
|
||||
<div class="input-group">
|
||||
<input type="file" id="fallbackCertFileInput" style="display:none"/>
|
||||
<input type="text" class="form-control" :placeholder="$t('domains.domainDialog.fallbackCertCertificatePlaceholder')" ng-model="domainConfigure.fallbackCert.certificateFileName" name="cert" onclick="getElementById('fallbackCertFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
|
||||
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackCertFileInput').click();"></i></span>
|
||||
<div v-if="tlsProvider === 'fallback'">
|
||||
<input type="file" ref="certificateFileInput" style="display: none" @change="onCertificateFileChange"/>
|
||||
<input type="file" ref="keyFileInput" style="display: none" @change="onKeyFileChange"/>
|
||||
|
||||
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-top: 15px">
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertCertificatePlaceholder') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="certificateFileName" @click="certificateFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
|
||||
<Button tool secondary icon="fa-solid fa-upload" @click="certificateFileInput.click()"></Button>
|
||||
</InputGroup>
|
||||
<label>{{ $t('domains.domainDialog.fallbackCertKeyPlaceholder') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput v-model="keyFileName" @click="keyFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
|
||||
<Button tool secondary icon="fa-solid fa-upload" @click="keyFileInput.click()"></Button>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</FormGroup>
|
||||
<FormGroup v-if="tlsProvider === 'fallback'">
|
||||
<div class="input-group">
|
||||
<input type="file" id="fallbackKeyFileInput" style="display:none"/>
|
||||
<input type="text" class="form-control" :placeholder="$t('domains.domainDialog.fallbackCertKeyPlaceholder')" ng-model="domainConfigure.fallbackCert.keyFileName" id="fallbackKeyInput" name="key" onclick="getElementById('fallbackKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
|
||||
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackKeyFileInput').click();"></i></span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!showAdvanced" style="margin-top: 15px" class="actionable" @click="showAdvanced = true">{{ $t('domains.domainDialog.advancedAction') }}</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { TextInput, InputGroup, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
|
||||
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
|
||||
import { ENDPOINTS_OVH } from '../constants.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
|
||||
@@ -27,6 +27,8 @@ defineProps({
|
||||
const provider = defineModel('provider');
|
||||
const dnsConfig = defineModel('dnsConfig');
|
||||
const tlsProvider = defineModel('tlsProvider');
|
||||
const zoneName = defineModel('zoneName');
|
||||
const customNameservers = defineModel('customNameservers');
|
||||
|
||||
const tlsProviders = [
|
||||
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
|
||||
@@ -51,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 = '';
|
||||
@@ -85,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('');
|
||||
@@ -128,9 +127,13 @@ 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" />
|
||||
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required @select="onProviderChange"/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
<!-- Route53 -->
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
|
||||
@@ -138,7 +141,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="secretAccessKeyInput">{{ $t('domains.domainDialog.route53SecretAccessKey') }}</label>
|
||||
<TextInput id="secretAccessKeyInput" v-model="dnsConfig.secretAccessKey" required />
|
||||
<MaskedInput id="secretAccessKeyInput" v-model="dnsConfig.secretAccessKey" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Google Cloud DNS -->
|
||||
@@ -146,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>
|
||||
@@ -155,7 +159,7 @@ function onGcdnsFileInputChange(event) {
|
||||
<!-- DigitalOcean -->
|
||||
<FormGroup v-if="provider === 'digitalocean'">
|
||||
<label for="digitalOceanTokenInput">{{ $t('domains.domainDialog.digitalOceanToken') }}</label>
|
||||
<TextInput id="digitalOceanTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="digitalOceanTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Gandi -->
|
||||
@@ -165,7 +169,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'gandi'">
|
||||
<label for="gandiApiKeyInput">{{ $t('domains.domainDialog.gandiApiKey') }}</label>
|
||||
<TextInput id="gandiApiKeyInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="gandiApiKeyInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- GoDaddy -->
|
||||
@@ -175,7 +179,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'godaddy'">
|
||||
<label for="godaddyApiSecretInput">{{ $t('domains.domainDialog.goDaddyApiSecret') }}</label>
|
||||
<TextInput for="godaddyApiSecretInput" v-model="dnsConfig.apiSecret" required />
|
||||
<MaskedInput for="godaddyApiSecretInput" v-model="dnsConfig.apiSecret" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Netcup -->
|
||||
@@ -189,7 +193,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'netcup'">
|
||||
<label for="netcupApiPasswordInput">{{ $t('domains.domainDialog.netcupApiPassword') }}</label>
|
||||
<TextInput id="netcupApiPasswordInput" v-model="dnsConfig.apiPassword" required />
|
||||
<MaskedInput id="netcupApiPasswordInput" v-model="dnsConfig.apiPassword" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- OVH -->
|
||||
@@ -207,7 +211,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'ovh'">
|
||||
<label for="ovhAppSecretInput">{{ $t('domains.domainDialog.ovhAppSecret') }}</label>
|
||||
<TextInput id="ovhAppSecretInput" v-model="dnsConfig.appSecret" required />
|
||||
<MaskedInput id="ovhAppSecretInput" v-model="dnsConfig.appSecret" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Porkbun -->
|
||||
@@ -217,61 +221,61 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'porkbun'">
|
||||
<label for="porkbunSecretapikeyInput">{{ $t('domains.domainDialog.porkbunSecretapikey') }}</label>
|
||||
<TextInput id="porkbunSecretapikeyInput" v-model="dnsConfig.secretapikey" required />
|
||||
<MaskedInput id="porkbunSecretapikeyInput" v-model="dnsConfig.secretapikey" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Cloudflare -->
|
||||
<FormGroup v-if="provider === 'cloudflare'">
|
||||
<label for="cloudflareTokenTypeInput">{{ $t('domains.domainDialog.cloudflareTokenType') }}</label>
|
||||
<SingleSelect v-model="dnsConfig.tokenType" :options="cloudflareTokenTypes" option-key="value" option-label="name" />
|
||||
<SingleSelect v-model="dnsConfig.tokenType" :options="cloudflareTokenTypes" option-key="value" option-label="name" required/>
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'cloudflare' && dnsConfig.tokenType === 'GlobalApiKey' || dnsConfig.tokenType === 'ApiToken'">
|
||||
<label for="cloudflareTokenInput" v-show="dnsConfig.tokenType === 'GlobalApiKey'">{{ $t('domains.domainDialog.cloudflareTokenTypeGlobalApiKey') }}</label>
|
||||
<label for="cloudflareTokenInput" v-show="dnsConfig.tokenType === 'ApiToken'">{{ $t('domains.domainDialog.cloudflareTokenTypeApiToken') }}</label>
|
||||
<TextInput id="cloudflareTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="cloudflareTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'cloudflare' && dnsConfig.tokenType === 'GlobalApiKey'">
|
||||
<label for="cloudflareEmailInput">{{ $t('domains.domainDialog.cloudflareEmail') }}</label>
|
||||
<TextInput id="cloudflareEmailInput" type="email" v-model="dnsConfig.email" :required="dnsConfig.tokenType === 'GlobalApiKey'" />
|
||||
<TextInput id="cloudflareEmailInput" type="email" v-model="dnsConfig.email" required />
|
||||
</FormGroup>
|
||||
<div v-if="provider === 'cloudflare'">
|
||||
<Checkbox v-model="dnsConfig.defaultProxyStatus" :label="$t('domains.domainDialog.cloudflareDefaultProxyStatus')" help-url="https://docs.cloudron.io/domains/#cloudflare-dns" style="margin-top: 10px" />
|
||||
<Checkbox v-model="dnsConfig.defaultProxyStatus" :label="$t('domains.domainDialog.cloudflareDefaultProxyStatus')" help-url="https://docs.cloudron.io/domains/#cloudflare-dns" />
|
||||
</div>
|
||||
|
||||
<!-- Linode -->
|
||||
<FormGroup v-if="provider === 'linode'">
|
||||
<label for="linodeTokenInput">{{ $t('domains.domainDialog.linodeToken') }}</label>
|
||||
<TextInput id="linodeTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="linodeTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Bunny -->
|
||||
<FormGroup v-if="provider === 'bunny'">
|
||||
<label for="bunnyAccessKeyInput">{{ $t('domains.domainDialog.bunnyAccessKey') }}</label>
|
||||
<TextInput id="bunnyAccessKeyInput" v-model="dnsConfig.accessKey" required />
|
||||
<MaskedInput id="bunnyAccessKeyInput" v-model="dnsConfig.accessKey" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- dnsimple -->
|
||||
<FormGroup v-if="provider === 'dnsimple'">
|
||||
<label for="dnsimpleAccessTokenInput">{{ $t('domains.domainDialog.dnsimpleAccessToken') }}</label>
|
||||
<TextInput id="dnsimpleAccessTokenInput" v-model="dnsConfig.accessToken" required />
|
||||
<MaskedInput id="dnsimpleAccessTokenInput" v-model="dnsConfig.accessToken" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Hetzner -->
|
||||
<FormGroup v-if="provider === 'hetzner'">
|
||||
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
|
||||
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
|
||||
<TextInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Vultr -->
|
||||
<FormGroup v-if="provider === 'vultr'">
|
||||
<label for="vultrTokenInput">{{ $t('domains.domainDialog.vultrToken') }}</label>
|
||||
<TextInput id="vultrTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="vultrTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- deSEC -->
|
||||
<FormGroup v-if="provider === 'desec'">
|
||||
<label for="deSecTokenInput">{{ $t('domains.domainDialog.deSecToken') }}</label>
|
||||
<TextInput id="deSecTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="deSecTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Name.com -->
|
||||
@@ -281,7 +285,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'namecom'">
|
||||
<label for="nameComTokenInput">{{ $t('domains.domainDialog.nameComApiToken') }}</label>
|
||||
<TextInput id="nameComTokenInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="nameComTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Namecheap -->
|
||||
@@ -291,9 +295,9 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'namecheap'">
|
||||
<label for="namecheapApiKeyInput">{{ $t('domains.domainDialog.namecheapApiKey') }}</label>
|
||||
<TextInput id="namecheapApiKeyInput" v-model="dnsConfig.token" required />
|
||||
<MaskedInput id="namecheapApiKeyInput" v-model="dnsConfig.token" required />
|
||||
<div class="warning-label" v-if="provider === 'namecheap'" v-html="$t('domains.domainDialog.namecheapInfo')"></div>
|
||||
</FormGroup>
|
||||
<div class="warning-label" v-if="provider === 'namecheap'" v-html="$t('domains.domainDialog.namecheapInfo')"></div>
|
||||
|
||||
<!-- INWX -->
|
||||
<FormGroup v-if="provider === 'inwx'">
|
||||
@@ -302,17 +306,22 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
<FormGroup v-if="provider === 'inwx'">
|
||||
<label for="inwxPasswordInput">{{ $t('domains.domainDialog.inwxPassword') }}</label>
|
||||
<TextInput id="inwxPasswordInput" v-model="dnsConfig.password" required />
|
||||
<MaskedInput id="inwxPasswordInput" v-model="dnsConfig.password" required />
|
||||
</FormGroup>
|
||||
|
||||
<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 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>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
<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/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>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,12 @@ const props = defineProps({
|
||||
helpUrl: { type: String, required: false },
|
||||
value: { type: String, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false },
|
||||
multiline: { type: Boolean, default: false },
|
||||
markdown: { type: Boolean, default: false },
|
||||
rows: { type: Number, default: 2 },
|
||||
maxlength: { type: Number, default: -1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
@@ -41,6 +43,7 @@ function startEdit() {
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.required && !draftValue.value) return;
|
||||
emit('save', draftValue.value);
|
||||
}
|
||||
|
||||
@@ -54,13 +57,13 @@ 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"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving"></textarea>
|
||||
<Button tool @click="save" :disabled="saving">{{ $t('main.dialog.save') }}</Button>
|
||||
<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>
|
||||
<div v-else>
|
||||
<div v-if="markdown" v-html="marked.parse(value)"></div>
|
||||
<div v-if="markdown" v-html="marked.parseInline(value)"></div>
|
||||
<div v-else>{{ value }}</div>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted, computed, inject } from 'vue';
|
||||
import { Dialog, Button, Icon, FormGroup, SingleSelect, Checkbox, TextInput, ProgressBar, PasswordInput } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { Dialog, Button, Icon, FormGroup, MaskedInput, SingleSelect, Checkbox, TextInput, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import Section from './Section.vue';
|
||||
import UserDirectoryModel from '../models/UserDirectoryModel.js';
|
||||
@@ -46,15 +46,26 @@ const bindDn = ref('');
|
||||
const bindPassword = ref('');
|
||||
const autoCreate = ref(false);
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!provider.value) return false;
|
||||
if (!url.value) return false;
|
||||
if (!baseDn.value) return false;
|
||||
if (!filter.value) return false;
|
||||
if (bindDn.value && !bindPassword.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
function onProviderChange() {
|
||||
url.value = '';
|
||||
acceptSelfSignedCerts.value = false;
|
||||
baseDn.value = '';
|
||||
filter.value = '';
|
||||
usernameField.value = '';
|
||||
syncGroups.value = false;
|
||||
groupBaseDn.value = '';
|
||||
groupFilter.value = '';
|
||||
groupnameField.value = '';
|
||||
bindDn.value = '';
|
||||
bindPassword.value = '';
|
||||
autoCreate.value = false;
|
||||
}
|
||||
|
||||
async function onConfigure() {
|
||||
if (!features.value.externalLdap) return subscriptionRequiredDialog.value.open();
|
||||
@@ -76,10 +87,12 @@ async function onConfigure() {
|
||||
autoCreate.value = config.value.autoCreate;
|
||||
|
||||
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 = {};
|
||||
@@ -91,7 +104,7 @@ async function onSubmit() {
|
||||
config.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
|
||||
config.autoCreate = autoCreate.value;
|
||||
config.syncGroups = syncGroups.value;
|
||||
config.bindPassword = bindPassword.value;
|
||||
if (bindPassword.value) config.bindPassword = bindPassword.value;
|
||||
|
||||
// those values are known and thus overwritten
|
||||
config.baseDn = 'ou=users,dc=cloudron';
|
||||
@@ -115,7 +128,7 @@ async function onSubmit() {
|
||||
|
||||
if (bindDn.value) {
|
||||
config.bindDn = bindDn.value;
|
||||
config.bindPassword = bindPassword.value;
|
||||
if (bindPassword.value) config.bindPassword = bindPassword.value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,7 +243,7 @@ onMounted(async () => {
|
||||
:title="$t('users.externalLdapDialog.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="!editBusy && isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
@@ -239,70 +252,67 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup>
|
||||
<label for="ldapProvider">{{ $t('users.externalLdap.provider') }} <sup><a href="https://docs.cloudron.io/user-directory/#external-directory" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="ldapProvider" v-model="provider" :options="availableProviders" option-key="value" option-label="name" />
|
||||
<SingleSelect id="ldapProvider" @select="onProviderChange" v-model="provider" :options="availableProviders" option-key="value" option-label="name"/>
|
||||
<div class="warning-label" v-show="provider === 'noop' && config.provider !== 'noop'">{{ $t('users.externalLdap.disableWarning') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<p class="text-small text-warning" v-show="provider === 'noop' && config.provider !== 'noop'">{{ $t('users.externalLdap.disableWarning') }}</p>
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<div v-show="provider !== 'noop'">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid" />
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
<TextInput id="configUrlInput" v-model="url" placeholder="ldaps://example.com:636" required/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
<TextInput id="configUrlInput" v-model="url" placeholder="ldaps://example.com:636" />
|
||||
</FormGroup>
|
||||
<Checkbox v-model="acceptSelfSignedCerts" :label="$t('users.externalLdap.acceptSelfSignedCert')" />
|
||||
<p class="has-error" v-show="editError.acceptSelfSignedCerts">{{ $t('users.externalLdap.errorSelfSignedCert') }}</p>
|
||||
|
||||
<Checkbox v-model="acceptSelfSignedCerts" :label="$t('users.externalLdap.acceptSelfSignedCert')" />
|
||||
<p class="has-error" v-show="editError.acceptSelfSignedCerts">{{ $t('users.externalLdap.errorSelfSignedCert') }}</p>
|
||||
<FormGroup :class="{ 'has-error': editError.baseDn }" v-if="provider !== 'cloudron'">
|
||||
<label for="baseDnInput">{{ $t('users.externalLdap.baseDn') }}</label>
|
||||
<TextInput v-model="baseDn" id="baseDnInput" placeholder="ou=users,dc=example,dc=com" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.baseDn }" v-show="provider !== 'cloudron'">
|
||||
<label for="baseDnInput">{{ $t('users.externalLdap.baseDn') }}</label>
|
||||
<TextInput v-model="baseDn" id="baseDnInput" placeholder="ou=users,dc=example,dc=com" :required="provider !== 'cloudron'" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.filter }" v-if="provider !== 'cloudron'">
|
||||
<label for="filterInput">{{ $t('users.externalLdap.filter') }}</label>
|
||||
<TextInput v-model="filter" id="filterInput" placeholder="(objectClass=inetOrgPerson)" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.filter }" v-show="provider !== 'cloudron'">
|
||||
<label for="filterInput">{{ $t('users.externalLdap.filter') }}</label>
|
||||
<TextInput v-model="filter" id="filterInput" placeholder="(objectClass=inetOrgPerson)" :required="provider !== 'cloudron'" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.usernameField }" v-if="provider !== 'cloudron'">
|
||||
<label for="usernameFieldInput">{{ $t('users.externalLdap.usernameField') }}</label>
|
||||
<TextInput v-model="usernameField" id="usernameFieldInput" placeholder="uid or sAMAcountName" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.usernameField }" v-show="provider !== 'cloudron'">
|
||||
<label for="usernameFieldInput">{{ $t('users.externalLdap.usernameField') }}</label>
|
||||
<TextInput v-model="usernameField" id="usernameFieldInput" placeholder="uid or sAMAcountName" />
|
||||
</FormGroup>
|
||||
<Checkbox v-model="syncGroups" :label="$t('users.externalLdap.syncGroups')" />
|
||||
|
||||
<Checkbox v-model="syncGroups" :label="$t('users.externalLdap.syncGroups')" />
|
||||
<FormGroup :class="{ 'has-error': editError.groupBaseDn }" v-if="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupBaseDnInput">{{ $t('users.externalLdap.groupBaseDn') }}</label>
|
||||
<TextInput v-model="groupBaseDn" id="groupBaseDnInput" placeholder="ou=groups,dc=example,dc=com" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.groupBaseDn }" v-show="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupBaseDnInput">{{ $t('users.externalLdap.groupBaseDn') }}</label>
|
||||
<TextInput v-model="groupBaseDn" id="groupBaseDnInput" placeholder="ou=groups,dc=example,dc=com" :required="syncGroups && provider !== 'cloudron'" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.groupFilter }" v-if="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupFilterInput">{{ $t('users.externalLdap.groupFilter') }}</label>
|
||||
<TextInput v-model="groupFilter" id="groupFilterInput" placeholder="(objectClass=groupOfNames)" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.groupFilter }" v-show="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupFilterInput">{{ $t('users.externalLdap.groupFilter') }}</label>
|
||||
<TextInput v-model="groupFilter" id="groupFilterInput" placeholder="(objectClass=groupOfNames)" :required="syncGroups && provider !== 'cloudron'" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.groupnameField }" v-if="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupnameFieldInput">{{ $t('users.externalLdap.groupnameField') }}</label>
|
||||
<TextInput v-model="groupnameField" id="groupnameFieldInput" placeholder="cn" required />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.groupnameField }" v-show="syncGroups && provider !== 'cloudron'">
|
||||
<label for="groupnameFieldInput">{{ $t('users.externalLdap.groupnameField') }}</label>
|
||||
<TextInput v-model="groupnameField" id="groupnameFieldInput" placeholder="cn" :required="syncGroups && provider !== 'cloudron'" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.credentials }" v-if="provider !== 'cloudron'">
|
||||
<label for="bindDnInput">{{ $t('users.externalLdap.bindUsername') }}</label>
|
||||
<TextInput v-model="bindDn" id="bindDnInput" placeholder="uid=admin,ou=Users,dc=example,dc=com" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.credentials }" v-show="provider !== 'cloudron'">
|
||||
<label for="bindDnInput">{{ $t('users.externalLdap.bindUsername') }}</label>
|
||||
<TextInput v-model="bindDn" id="bindDnInput" placeholder="uid=admin,ou=Users,dc=example,dc=com" />
|
||||
</FormGroup>
|
||||
<FormGroup :class="{ 'has-error': editError.credentials }">
|
||||
<label for="bindPasswordInput">{{ $t('users.externalLdap.bindPassword') }}</label>
|
||||
<MaskedInput v-model="bindPassword" id="bindPasswordInput" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.credentials }">
|
||||
<label for="bindPasswordInput">{{ $t('users.externalLdap.bindPassword') }}</label>
|
||||
<PasswordInput v-model="bindPassword" id="bindPasswordInput" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="autoCreate" :label="$t('users.externalLdap.autocreateUsersOnLogin')" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
<Checkbox v-model="autoCreate" :label="$t('users.externalLdap.autocreateUsersOnLogin')" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('users.externalLdap.title')" :title-badge="!features.externalLdap ? 'Upgrade' : ''">
|
||||
|
||||
+17
-12
@@ -1,9 +1,9 @@
|
||||
<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';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
@@ -113,15 +113,16 @@ onMounted(async () => {
|
||||
@confirm="onBlocklistSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.firewall.configure.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBlocklistBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
|
||||
<FormGroup>
|
||||
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea id="blocklistInput" v-model="editBlocklist" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -138,15 +139,16 @@ onMounted(async () => {
|
||||
@confirm="onTrustedIpsSubmit()"
|
||||
>
|
||||
<div>
|
||||
<p class="small">{{ $t('network.trustedIps.description') }}</p>
|
||||
|
||||
<div class="small">{{ $t('network.trustedIps.description') }}</div>
|
||||
<br/>
|
||||
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editTrustedIpsBusy">
|
||||
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
|
||||
<FormGroup>
|
||||
<label for="">{{ $t('network.trustedIpRanges') }}</label>
|
||||
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
|
||||
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
|
||||
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
|
||||
<textarea v-model="editTrustedIps" rows="4"></textarea>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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);
|
||||
@@ -64,36 +63,10 @@ const uploadMenuModel = [{
|
||||
action: onUploadFile,
|
||||
}, {
|
||||
icon: 'fa-regular fa-folder-open',
|
||||
label: t('filemanager.toolbar.newFolder'),
|
||||
label: t('filemanager.toolbar.uploadFolder'),
|
||||
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();
|
||||
@@ -109,9 +82,10 @@ async function onNewFile() {
|
||||
message: t('filemanager.newFileDialog.title'),
|
||||
value: '',
|
||||
required: true,
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('filemanager.newFileDialog.create'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!newFileName) return;
|
||||
@@ -125,9 +99,10 @@ async function onNewFolder() {
|
||||
message: t('filemanager.newDirectoryDialog.title'),
|
||||
value: '',
|
||||
required: true,
|
||||
confirmStyle: 'success',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: t('filemanager.newFileDialog.create'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!newFolderName) return;
|
||||
@@ -153,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)
|
||||
@@ -175,37 +175,66 @@ async function onDrop(targetFolder, dataTransfer, files) {
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntries(dirReader) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
function setRelativePath(file, entry) {
|
||||
const relativePath = (entry.fullPath || entry.name || '').replace(/^\//, '');
|
||||
if (relativePath) {
|
||||
// trying with defineProperty() to better mimic native behavior adding a non-enumeratible property
|
||||
try {
|
||||
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath });
|
||||
} catch {
|
||||
file.webkitRelativePath = relativePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// wrapper as chrome only returns files in batches of 100 entries
|
||||
async function readAllEntries(dirReader) {
|
||||
const all = [];
|
||||
let batch;
|
||||
do {
|
||||
batch = await new Promise((resolve, reject) => {
|
||||
dirReader.readEntries(resolve, reject);
|
||||
});
|
||||
all.push(...batch);
|
||||
} while (batch.length > 0);
|
||||
return all;
|
||||
}
|
||||
|
||||
const fileList = [];
|
||||
async function traverseFileTree(item) {
|
||||
if (item.isFile) {
|
||||
fileList.push(await getFile(item));
|
||||
const file = await getFile(item);
|
||||
setRelativePath(file, item);
|
||||
fileList.push(file);
|
||||
} else if (item.isDirectory) {
|
||||
// Get folder contents
|
||||
const dirReader = item.createReader();
|
||||
const entries = await readEntries(dirReader);
|
||||
const entries = await readAllEntries(dirReader);
|
||||
|
||||
for (const i in entries) {
|
||||
await traverseFileTree(entries[i], item.name);
|
||||
await traverseFileTree(entries[i]);
|
||||
}
|
||||
} else {
|
||||
console.log('Skipping uknown file type', item);
|
||||
console.log('Skipping unknown file type', item);
|
||||
}
|
||||
}
|
||||
|
||||
// collect all files to upload
|
||||
for (const item of dataTransfer.items) {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
|
||||
|
||||
if (!entry) {
|
||||
const file = item.getAsFile ? item.getAsFile() : null;
|
||||
if (file) fileList.push(file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile) {
|
||||
fileList.push(await getFile(entry));
|
||||
const file = await getFile(entry);
|
||||
setRelativePath(file, entry);
|
||||
fileList.push(file);
|
||||
} else if (entry.isDirectory) {
|
||||
await traverseFileTree(entry, sanitize(`${cwd.value}/${targetFolder}`));
|
||||
await traverseFileTree(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,8 +268,9 @@ async function deleteHandler(files) {
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.removeDialog.reallyDelete'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.no'),
|
||||
confirmLabel: t('main.dialog.delete'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
@@ -369,9 +399,9 @@ 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',
|
||||
});
|
||||
|
||||
@@ -443,7 +473,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
appLink.value = `https://${result.body.fqdn}`;
|
||||
title.value = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
|
||||
title.value = `${result.body.label || result.body.fqdn} ` + (result.body.manifest ? `(${result.body.manifest.title})` : '');
|
||||
} else if (type === 'volume') {
|
||||
let error, result;
|
||||
try {
|
||||
@@ -506,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>
|
||||
@@ -518,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>
|
||||
@@ -526,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"
|
||||
@@ -545,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"
|
||||
@@ -553,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>
|
||||
@@ -583,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
|
||||
<script setup>
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
import Chart from 'chart.js/auto';
|
||||
import { prettyDecimalSize, prettyLongDate, prettyShortDate } from '@cloudron/pankow/utils';
|
||||
import { prettyDecimalSize, formatDate } from '@cloudron/pankow/utils';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
|
||||
Chart.register(annotationPlugin);
|
||||
@@ -21,13 +20,70 @@ let yscaleUnit = null;
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
subtext: String,
|
||||
footer: String,
|
||||
period: Object, // { hours, format, tooltpFormat }
|
||||
yscale: String, // cpu, memory
|
||||
memory: Number,
|
||||
cpuCount: Number,
|
||||
highMark: Number,
|
||||
highMarkLabel: String,
|
||||
});
|
||||
|
||||
function renderTooltip(context) {
|
||||
// console.log(context); { chart, tooltip } tooltip has { title, body }
|
||||
if (!tooltipElem.value) return;
|
||||
|
||||
const { /*chart, */ tooltip } = context;
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipElem.value.style.opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 => { return { label: item.lines }; });
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</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;
|
||||
}
|
||||
|
||||
tooltipElem.value.style.opacity = 1;
|
||||
tooltipElem.value.style.position = 'absolute';
|
||||
|
||||
tooltipElem.value.classList.remove('graphs-tooltip-caret-left', 'graphs-tooltip-caret-right');
|
||||
|
||||
if (tooltip.chart.width/2 < tooltip.caretX) {
|
||||
tooltipElem.value.style.right = (tooltip.chart.width - tooltip.caretX) + 'px';
|
||||
tooltipElem.value.style.left = 'unset';
|
||||
tooltipElem.value.classList.add('graphs-tooltip-caret-right');
|
||||
} else {
|
||||
tooltipElem.value.style.right = 'unset';
|
||||
tooltipElem.value.style.left = tooltip.caretX + 'px';
|
||||
tooltipElem.value.classList.add('graphs-tooltip-caret-left');
|
||||
}
|
||||
|
||||
tooltipElem.value.style.top = '0px';
|
||||
tooltipElem.value.style.height = '100%';
|
||||
}
|
||||
|
||||
function createGraphOptions({ yscale, period, highMark }) {
|
||||
let startTime, endTime, stepSize, count; // x axis configuration values
|
||||
|
||||
@@ -52,56 +108,14 @@ function createGraphOptions({ yscale, period, highMark }) {
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: function(context) {
|
||||
if (!tooltipElem.value) return;
|
||||
|
||||
const tooltipModel = context.tooltip;
|
||||
if (tooltipModel.opacity === 0) {
|
||||
tooltipElem.value.style.opacity = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// Set Text
|
||||
if (tooltipModel.body) {
|
||||
const titleLines = tooltipModel.title || [];
|
||||
const bodyLines = tooltipModel.body.map(item => item.lines);
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
|
||||
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = tooltipModel.labelColors[i];
|
||||
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
|
||||
});
|
||||
|
||||
tooltipElem.value.innerHTML = innerHtml;
|
||||
}
|
||||
|
||||
|
||||
tooltipElem.value.style.opacity = 1;
|
||||
tooltipElem.value.style.position = 'absolute';
|
||||
|
||||
tooltipElem.value.classList.remove('graphs-tooltip-caret-left', 'graphs-tooltip-caret-right');
|
||||
|
||||
if (tooltipModel.chart.width/2 < tooltipModel.caretX) {
|
||||
tooltipElem.value.style.right = (tooltipModel.chart.width - tooltipModel.caretX) + 'px';
|
||||
tooltipElem.value.style.left = 'unset';
|
||||
tooltipElem.value.classList.add('graphs-tooltip-caret-right');
|
||||
} else {
|
||||
tooltipElem.value.style.right = 'unset';
|
||||
tooltipElem.value.style.left = tooltipModel.caretX + 'px';
|
||||
tooltipElem.value.classList.add('graphs-tooltip-caret-left');
|
||||
}
|
||||
|
||||
tooltipElem.value.style.top = 0 + 'px';
|
||||
tooltipElem.value.style.height = '100%';
|
||||
},
|
||||
callbacks: {
|
||||
title: (tooltipItem) => period.tooltipFormat === 'long' ? prettyLongDate(tooltipItem[0].raw.x) : prettyShortDate(tooltipItem[0].raw.x),
|
||||
callbacks: { // passed as title,body to the tooltip renderer
|
||||
title: (tooltipItem) => formatDate(period.tooltipFormat, tooltipItem[0].raw.x),
|
||||
label: (tooltipItem) => {
|
||||
const datasetLabel = tooltipItem.chart.data.datasets[tooltipItem.datasetIndex].label;
|
||||
return `<span style="font-family: fixed">${yscale.ticks.callback(tooltipItem.raw.y)}</span>: ${datasetLabel}`;
|
||||
return `${datasetLabel}: <span>${yscale.ticks.callback(tooltipItem.raw.y)}</span>`;
|
||||
}
|
||||
}
|
||||
},
|
||||
external: renderTooltip,
|
||||
},
|
||||
annotation: {
|
||||
annotations: {
|
||||
@@ -111,7 +125,18 @@ function createGraphOptions({ yscale, period, highMark }) {
|
||||
yMin: highMark,
|
||||
yMax: highMark,
|
||||
borderColor: 'rgb(139, 0, 0)',
|
||||
borderWidth: 0.5
|
||||
borderWidth: 0.5,
|
||||
label: {
|
||||
display: true,
|
||||
content: `Max ${props.title}`,
|
||||
position: 'end', // 'start', 'center', or 'end'
|
||||
backgroundColor: 'transparent',
|
||||
color: 'rgb(139, 0, 0)',
|
||||
yAdjust: -6,
|
||||
font: {
|
||||
size: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -128,7 +153,7 @@ function createGraphOptions({ yscale, period, highMark }) {
|
||||
maxRotation: 0, // don't rotate the labels
|
||||
callback: function (value) {
|
||||
if (period.hours === 0) return `${5-(value-this.min)/60000}min`;
|
||||
return moment(value).format(period.tickFormat);
|
||||
return formatDate(period.tickFormat, value);
|
||||
},
|
||||
count, // ignored when stepSize is set
|
||||
stepSize // for realtime graph, generate steps of 1min and appropriate tick text
|
||||
@@ -185,8 +210,10 @@ function pruneGraphData(dataset, options) {
|
||||
}
|
||||
|
||||
function advance() {
|
||||
graph.options.scales.x.min += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
graph.options.scales.x.max += LIVE_REFRESH_INTERVAL_MSECS;
|
||||
// advance is called in a timer and when the browser tab is in the background , it is unreliable. Use absolute time to set the scale
|
||||
const now = Date.now();
|
||||
graph.options.scales.x.min = now - 5*60*1000;
|
||||
graph.options.scales.x.max = now;
|
||||
graph.update('none');
|
||||
}
|
||||
|
||||
@@ -195,7 +222,6 @@ function pushData(datasetIndex, ...data) {
|
||||
graph.data.datasets[datasetIndex+index].data.push(transformData(item));
|
||||
pruneGraphData(graph.data.datasets[datasetIndex+index], graph.options);
|
||||
}
|
||||
graph.update('none');
|
||||
}
|
||||
|
||||
function onPeriodChanged() {
|
||||
@@ -301,17 +327,22 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="graph-container">
|
||||
<label>{{ title }} <span class="pull-right text-small">{{ subtext }}</span></label>
|
||||
<div class="graph">
|
||||
<canvas ref="graphNode"></canvas>
|
||||
<div ref="tooltipElem" class="graphs-tooltip"></div>
|
||||
</div>
|
||||
<div class="footer">{{ footer }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.graph-container {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.graphs label {
|
||||
margin: 16px 0;
|
||||
}
|
||||
@@ -319,7 +350,14 @@ defineExpose({
|
||||
.graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -341,13 +379,33 @@ defineExpose({
|
||||
border-right: 1px var(--pankow-color-primary) solid;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
font-weight: bold;
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
.graphs-tooltip-item {
|
||||
padding: 2px 0px;
|
||||
-webkit-text-stroke: 0.2px gray;
|
||||
@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';
|
||||
@@ -53,6 +53,7 @@ async function onSubmit() {
|
||||
|
||||
defineExpose({
|
||||
async open(g = null) {
|
||||
g = g ? JSON.parse(JSON.stringify(g)) : null; // make a copy
|
||||
group.value = g;
|
||||
name.value = g ? g.name : '';
|
||||
formError.value = {};
|
||||
@@ -62,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();
|
||||
}
|
||||
@@ -78,7 +79,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="group ? $t('users.editGroupDialog.title', { name: group.name }) : $t('users.addGroupDialog.title')"
|
||||
:title="group ? $t('users.editGroupDialog.title') : $t('users.addGroupDialog.title')"
|
||||
:confirm-label="group ? $t('main.dialog.save') : $t('users.group.addGroupAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== ''"
|
||||
@@ -102,13 +103,14 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="usersInput">{{ $t('users.group.users') }}</label>
|
||||
<div v-if="group?.source"><span ng-repeat="user in groupEdit.selectedUsers"> {{ (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">Access to Apps</label>
|
||||
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
|
||||
<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,50 +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);
|
||||
}
|
||||
|
||||
async function onMarkNotificationRead(notification) {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
|
||||
await refresh();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
@@ -77,7 +33,7 @@ function onSubscriptionRequired() {
|
||||
|
||||
const platformStatus = ref({
|
||||
message: '',
|
||||
isReady: true,
|
||||
state: '',
|
||||
});
|
||||
|
||||
let platformTimeoutId = 0;
|
||||
@@ -87,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', {
|
||||
@@ -97,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();
|
||||
});
|
||||
|
||||
@@ -111,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%;">
|
||||
@@ -150,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>
|
||||
|
||||
@@ -175,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);
|
||||
@@ -206,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);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ const props = defineProps({
|
||||
mode: { type: String, default: 'editable', required: true },
|
||||
src: { type: String, required: true },
|
||||
fallbackSrc: { type: String, required: true },
|
||||
size: { type: String, required: true },
|
||||
maxSize: { type: String, required: false },
|
||||
size: { type: Number, required: false, default: 512 },
|
||||
maxSize: { type: Number, required: false, default: 0 },
|
||||
displayHeight: { type: String, required: false },
|
||||
displayWidth: { type: String, required: false },
|
||||
disabled: { type: Boolean, required: false },
|
||||
@@ -109,22 +109,19 @@ function onChanged(event) {
|
||||
fr.onload = function () {
|
||||
const image = new Image();
|
||||
image.onload = function () {
|
||||
const size = props.size ? parseInt(props.size) : 512;
|
||||
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
if (maxSize) {
|
||||
if (image.naturalWidth > maxSize) {
|
||||
canvas.width = maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
|
||||
if (props.maxSize) {
|
||||
if (image.naturalWidth > props.maxSize) {
|
||||
canvas.width = props.maxSize;
|
||||
canvas.height = (image.naturalHeight / image.naturalWidth) * props.maxSize;
|
||||
} else {
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
}
|
||||
} else {
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
canvas.width = props.size;
|
||||
canvas.height = props.size;
|
||||
}
|
||||
|
||||
const imageDimensionRatio = image.width / image.height;
|
||||
@@ -155,8 +152,7 @@ function onChanged(event) {
|
||||
internalSrc.value = canvas.toDataURL('image/png');
|
||||
isChanged.value = true;
|
||||
|
||||
console.log('internalSrc is now some data url');
|
||||
emit('changed', file);
|
||||
emit('changed', dataURLtoFile(internalSrc.value, 'image.png'));
|
||||
};
|
||||
|
||||
image.src = fr.result;
|
||||
@@ -177,7 +173,6 @@ function onError() {
|
||||
|
||||
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
|
||||
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
|
||||
|
||||
<!-- Editable mode -->
|
||||
<template v-if="mode === 'editable'">
|
||||
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import { copyToClipboard } from '@cloudron/pankow/utils';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -27,11 +26,6 @@ function onGeneratePassword() {
|
||||
password.value = tmp;
|
||||
}
|
||||
|
||||
function onCopyPassword() {
|
||||
copyToClipboard(password.value);
|
||||
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
@@ -48,6 +42,7 @@ async function onSubmit() {
|
||||
|
||||
defineExpose({
|
||||
async open(u) {
|
||||
u = JSON.parse(JSON.stringify(u)); // make a copy
|
||||
user.value = u;
|
||||
success.value = false;
|
||||
password.value = '';
|
||||
@@ -61,22 +56,23 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('users.setGhostDialog.title', { username: user.username })"
|
||||
:title="$t('users.setGhostDialog.title')"
|
||||
:reject-label="success ? $t('main.dialog.close') : $t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="success ? '' : $t('users.setGhostDialog.setPassword')"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p>{{ $t('users.setGhostDialog.context', { username: user.username }) }}</p>
|
||||
<p>{{ $t('users.setGhostDialog.description') }}</p>
|
||||
<p class="text-danger" v-show="formError">{{ formError }}</p>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="none">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
<FormGroup>
|
||||
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="passwordInput" v-model="password" style="flex-grow: 1;"/>
|
||||
<Button tool v-if="success" @click="onCopyPassword()" icon="fa fa-clipboard" />
|
||||
<ClipboardButton v-if="success" :value="password" />
|
||||
<Button tool v-else @click="onGeneratePassword()" v-tooltip="$t('users.setGhostDialog.generatePassword')" icon="fa fa-key" />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
@@ -5,8 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, EmailInput, FormGroup, Button, InputGroup, ProgressBar } from '@cloudron/pankow';
|
||||
import { copyToClipboard } from '@cloudron/pankow/utils';
|
||||
import { Dialog, TextInput, ClipboardButton, EmailInput, FormGroup, Button, InputGroup, ProgressBar } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
|
||||
const emit = defineEmits([ 'refreshRequired' ]);
|
||||
@@ -21,11 +20,6 @@ const email = ref('');
|
||||
const success = ref(false);
|
||||
const busy = ref(false);
|
||||
|
||||
function onCopyToClipboard() {
|
||||
copyToClipboard(inviteLink.value);
|
||||
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
|
||||
}
|
||||
|
||||
async function onSendInvite() {
|
||||
const [error] = await usersModel.sendInviteEmail(user.value.id, email.value);
|
||||
if (error) return console.error(error);
|
||||
@@ -37,6 +31,7 @@ async function onSendInvite() {
|
||||
|
||||
defineExpose({
|
||||
async open(u) {
|
||||
u = JSON.parse(JSON.stringify(u)); // make a copy
|
||||
user.value = u;
|
||||
success.value = false;
|
||||
email.value = u.email || '';
|
||||
@@ -64,7 +59,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('users.invitationDialog.title', { username: user? (user.username || user.email) : '' })"
|
||||
:title="$t('users.invitationDialog.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
reject-style="secondary"
|
||||
>
|
||||
@@ -73,11 +68,13 @@ defineExpose({
|
||||
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>{{ $t('users.invitationDialog.context', { username: user? (user.username || user.email) : '' }) }}</p>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('users.invitationDialog.descriptionLink') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput style="flex-grow: 1;" v-model="inviteLink" readonly/>
|
||||
<Button tool @click="onCopyToClipboard()" icon="fa fa-clipboard"/>
|
||||
<ClipboardButton :value="inviteLink" />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
@@ -85,7 +82,7 @@ defineExpose({
|
||||
<label>{{ $t('users.invitationDialog.descriptionEmail') }}</label>
|
||||
<InputGroup>
|
||||
<EmailInput style="flex-grow: 1;" v-model="email"/>
|
||||
<Button @click="onSendInvite()" ng-disabled="invitation.busy" :loading="busy">{{ $t('users.invitationDialog.sendAction') }}</Button>
|
||||
<Button @click="onSendInvite()" :loading="busy">{{ $t('users.invitationDialog.sendAction') }}</Button>
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
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';
|
||||
|
||||
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
|
||||
const providers = [
|
||||
{ name: 'Disabled', value: 'noop' },
|
||||
{ name: 'Public IP', value: 'generic' },
|
||||
{ name: 'Static IP Address', value: 'fixed' },
|
||||
{ name: 'Network Interface', value: 'network-interface' }
|
||||
{ name: 'Static IP address', value: 'fixed' },
|
||||
{ name: 'Network interface', value: 'network-interface' }
|
||||
];
|
||||
|
||||
function prettyIpProviderName(provider) {
|
||||
switch (provider) {
|
||||
case 'noop': return 'Disabled';
|
||||
case 'generic': return 'Public IP';
|
||||
case 'fixed': return 'Static IP Address';
|
||||
case 'network-interface': return 'Network Interface';
|
||||
case 'fixed': return 'Static IP address';
|
||||
case 'network-interface': return 'Network interface';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -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,39 +105,39 @@ 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"/>
|
||||
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
|
||||
<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>
|
||||
|
||||
<p v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fixed -->
|
||||
<FormGroup v-show="editProvider === 'fixed'">
|
||||
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
|
||||
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
|
||||
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
|
||||
<div class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet -br addr <ClipboardAction plain value="ip -f inet -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
|
||||
<div class="has-error" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +145,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ip.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ip.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +160,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
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';
|
||||
|
||||
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
|
||||
const providers = [
|
||||
{ name: 'Disabled', value: 'noop' },
|
||||
{ name: 'Public IP', value: 'generic' },
|
||||
{ name: 'Static IP Address', value: 'fixed' },
|
||||
{ name: 'Network Interface', value: 'network-interface' }
|
||||
{ name: 'Static IP address', value: 'fixed' },
|
||||
{ name: 'Network interface', value: 'network-interface' }
|
||||
];
|
||||
|
||||
function prettyIpProviderName(provider) {
|
||||
switch (provider) {
|
||||
case 'noop': return 'Disabled';
|
||||
case 'generic': return 'Public IP';
|
||||
case 'fixed': return 'Static IP Address';
|
||||
case 'network-interface': return 'Network Interface';
|
||||
case 'fixed': return 'Static IP address';
|
||||
case 'network-interface': return 'Network interface';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
@@ -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,23 +105,23 @@ 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>
|
||||
|
||||
<div v-show="editProvider === 'generic'">
|
||||
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
|
||||
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</div>
|
||||
|
||||
@@ -130,9 +135,9 @@ onMounted(async () => {
|
||||
<!-- Network Interface -->
|
||||
<FormGroup v-show="editProvider === 'network-interface'">
|
||||
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
|
||||
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet6 -br addr <ClipboardAction plain value="ip -f inet6 -br addr" /></div>
|
||||
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
|
||||
<div class="error-label" v-show="editError.ifname">{{ editError.ifname }}</div>
|
||||
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet6 -br addr</code></p>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -140,10 +145,6 @@ onMounted(async () => {
|
||||
</Dialog>
|
||||
|
||||
<Section :title="$t('network.ipv6.title')">
|
||||
<template #header-buttons>
|
||||
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</template>
|
||||
|
||||
<div>{{ $t('network.ipv6.description') }}</div>
|
||||
<br/>
|
||||
|
||||
@@ -159,6 +160,8 @@ onMounted(async () => {
|
||||
<div class="info-label">{{ $t('network.ip.interface') }}</div>
|
||||
<div class="info-value">{{ interfaceName }}</div>
|
||||
</div>
|
||||
|
||||
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
|
||||
</Section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
|
||||
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';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import UserDirectoryModel from '../models/UserDirectoryModel.js';
|
||||
|
||||
const domainsModel = DomainsModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const userDirectoryModel = UserDirectoryModel.create();
|
||||
|
||||
const adminDomain = ref({});
|
||||
const editError = ref({});
|
||||
const busy = ref(false);
|
||||
const enabled = ref(false);
|
||||
const ldapUrl = ref('');
|
||||
const secret = ref('');
|
||||
const allowlist = ref('');
|
||||
|
||||
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;
|
||||
|
||||
busy.value = true;
|
||||
editError.value = {};
|
||||
|
||||
const [error] = await userDirectoryModel.setExposedLdapConfig({ enabled: enabled.value, allowlist: allowlist.value, secret: secret.value });
|
||||
busy.value = false;
|
||||
|
||||
if (error) {
|
||||
if (error.status === 400) {
|
||||
if (error.body.message.indexOf('secret') !== -1) editError.value.secret = error.body.message;
|
||||
else editError.value.allowlist = error.body.message;
|
||||
} else {
|
||||
editError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await domainsModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
const domains = result;
|
||||
|
||||
[error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
|
||||
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
|
||||
|
||||
[error, result] = await userDirectoryModel.getExposedLdapConfig();
|
||||
if (error) return console.error(error);
|
||||
|
||||
enabled.value = result.enabled;
|
||||
secret.value = result.secret;
|
||||
allowlist.value = result.allowlist;
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section :title="$t('users.exposedLdap.title')">
|
||||
<div>{{ $t('users.exposedLdap.description') }}</div>
|
||||
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<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"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="ldapUrlInput">{{ $t('users.exposedLdap.secret.url') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="ldapUrlInput" v-model="ldapUrl" readonly style="flex-grow: 1;"/>
|
||||
<ClipboardButton :value="ldapUrl" />
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-show="adminDomain.provider === 'cloudflare'">{{ $t('users.exposedLdap.cloudflarePortWarning') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="secretInput">{{ $t('users.exposedLdap.secret.label') }}</label>
|
||||
<div description v-html="$t('users.exposedLdap.secret.description', { userDN: 'cn=admin,ou=system,dc=cloudron' })"></div>
|
||||
<PasswordInput id="secretInput" v-model="secret" required :disabled="!enabled" />
|
||||
<div class="has-error" v-show="editError.secret">{{ editError.secret }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="allowlistInput">{{ $t('users.exposedLdap.ipRestriction.label') }}</label>
|
||||
<div description v-html="$t('users.exposedLdap.ipRestriction.description')"></div>
|
||||
<textarea id="allowlistInput" v-model="allowlist" rows="4" required :disabled="!enabled"></textarea>
|
||||
<small style="helper-text">{{ $t('users.exposedLdap.ipRestriction.placeholder') }}</small>
|
||||
<div class="has-error" v-show="editError.allowlist">{{ editError.allowlist }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
|
||||
<br/>
|
||||
<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>
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, FormGroup, SingleSelect, TextInput, NumberInput, Checkbox, PasswordInput } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef, computed, onMounted } from 'vue';
|
||||
import { Button, Dialog, FormGroup, SingleSelect, TextInput, NumberInput, Checkbox, MaskedInput } from '@cloudron/pankow';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
import MailModel from '../models/MailModel.js';
|
||||
import { RELAY_PROVIDERS } from '../constants.js';
|
||||
import { prettyRelayProviderName } from '../utils';
|
||||
|
||||
const props = defineProps(['domain']);
|
||||
const props = defineProps({
|
||||
domain: { type: String, required: true },
|
||||
adminDomain: { type: String, required: true }
|
||||
});
|
||||
|
||||
const mailModel = MailModel.create();
|
||||
|
||||
const providers = [
|
||||
{ provider: 'cloudron-smtp', name: 'Built-in SMTP server' },
|
||||
{ provider: 'external-smtp', name: 'External SMTP server', host: '', port: 587 },
|
||||
{ provider: 'external-smtp-noauth', name: 'External SMTP server (No Authentication)', host: '', port: 587 },
|
||||
{ provider: 'ses-smtp', name: 'Amazon SES', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, spfDoc: 'https://docs.aws.amazon.com/ses/latest/DeveloperGuide/spf.html' },
|
||||
{ provider: 'brevo-smtp', name: 'Brevo', host: 'smtp-relay.brevo.com', port: 587, spfDoc: 'https://help.brevo.com/hc/en-us/articles/12163873383186-Authenticate-your-domain-with-Brevo-Brevo-code-DKIM-DMARC' },
|
||||
{ provider: 'elasticemail-smtp', name: 'Elastic Email', host: 'smtp.elasticemail.com', port: 587, spfDoc: 'https://elasticemail.com/blog/marketing_tips/common-spf-errors' },
|
||||
{ provider: 'google-smtp', name: 'Google', host: 'smtp.gmail.com', port: 587, spfDoc: 'https://support.google.com/a/answer/33786?hl=en' },
|
||||
{ provider: 'mailgun-smtp', name: 'Mailgun', host: 'smtp.mailgun.org', port: 587, spfDoc: 'https://www.mailgun.com/blog/white-labeling-dns-records-your-customers-tips-tricks' },
|
||||
{ provider: 'mailjet-smtp', name: 'Mailjet', host: 'in-v3.mailjet.com', port: 587, spfDoc: 'https://app.mailjet.com/docs/spf-dkim-guide' },
|
||||
{ provider: 'office365-legacy-smtp', name: 'Office 365', host: 'smtp-legacy.office365.com', port: 587 }, // uses "login" AUTH instead of "plain"
|
||||
{ provider: 'postmark-smtp', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587, spfDoc: 'https://postmarkapp.com/support/article/1092-how-do-i-set-up-spf-for-postmark' },
|
||||
{ provider: 'sendgrid-smtp', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587, username: 'apikey', spfDoc: 'https://sendgrid.com/docs/ui/account-and-settings/spf-records/' },
|
||||
{ provider: 'sparkpost-smtp', name: 'SparkPost', host: 'smtp.sparkpostmail.com', port: 587, username: 'SMTP_Injection', spfDoc: 'https://www.sparkpost.com/resources/email-explained/spf-sender-policy-framework/' },
|
||||
{ provider: 'noop', name: 'Disable' },
|
||||
];
|
||||
|
||||
const providerSpfDoc = computed(() => {
|
||||
const tmp = providers.find(p => p.provider === provider.value);
|
||||
const tmp = RELAY_PROVIDERS.find(p => p.provider === provider.value);
|
||||
return (tmp ? tmp.spfDoc : '') || '';
|
||||
});
|
||||
|
||||
@@ -35,7 +23,7 @@ const mailConfig = ref({});
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const adminDomain = ref('');
|
||||
const currentProvider = ref('cloudron-smtp');
|
||||
const provider = ref('cloudron-smtp');
|
||||
const host = ref('');
|
||||
const port = ref(1);
|
||||
@@ -63,6 +51,12 @@ function usesPasswordAuth(provider) {
|
||||
|| provider === 'mailjet-smtp';
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
// reset the form
|
||||
host.value = '';
|
||||
@@ -73,7 +67,7 @@ function onProviderChange() {
|
||||
password.value = '';
|
||||
|
||||
// prefill from preset
|
||||
const tmp = providers.find(p => p.provider === provider.value);
|
||||
const tmp = RELAY_PROVIDERS.find(p => p.provider === provider.value);
|
||||
if (!tmp) return;
|
||||
|
||||
if (tmp.host) host.value = tmp.host;
|
||||
@@ -98,9 +92,13 @@ async function onShowDialog() {
|
||||
serverApiToken.value = result.relay.serverApiToken;
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -110,7 +108,7 @@ async function onSubmit() {
|
||||
|
||||
if (usesExternalServer(data.provider)) {
|
||||
data.username = username.value;
|
||||
data.password = password.value;
|
||||
if (password.value) data.password = password.value;
|
||||
data.host = host.value;
|
||||
data.port = parseInt(port.value);
|
||||
data.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
|
||||
@@ -119,15 +117,15 @@ async function onSubmit() {
|
||||
|
||||
// fill in provider specific username/password usage
|
||||
if (data.provider === 'postmark-smtp') {
|
||||
data.username = serverApiToken.value;
|
||||
data.password = serverApiToken.value;
|
||||
if (serverApiToken.value) data.username = serverApiToken.value;
|
||||
if (serverApiToken.value) data.password = serverApiToken.value;
|
||||
data.forceFromAddress = true; // postmark requires the "From:" in mail to be a Sender Signature
|
||||
} else if (data.provider === 'sendgrid-smtp') {
|
||||
data.username = 'apikey';
|
||||
data.password = serverApiToken.value;
|
||||
if (serverApiToken.value) data.password = serverApiToken.value;
|
||||
} else if (data.provider === 'sparkpost-smtp') {
|
||||
data.username = 'SMTP_Injection';
|
||||
data.password = serverApiToken.value;
|
||||
if (serverApiToken.value) data.password = serverApiToken.value;
|
||||
}
|
||||
|
||||
const [error] = await mailModel.setMailRelay(props.domain, data);
|
||||
@@ -137,11 +135,21 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
currentProvider.value = provider.value;
|
||||
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await mailModel.config(props.domain);
|
||||
if (error) return console.error(error);
|
||||
|
||||
provider.value = result.relay.provider;
|
||||
currentProvider.value = result.relay.provider;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -150,23 +158,24 @@ async function onSubmit() {
|
||||
:title="$t('email.outbound.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="busy"
|
||||
:reject-label="busy ? '' : $t('main.dialog.cancel')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<SingleSelect v-model="provider" :options="providers" option-key="provider" option-label="name" @select="onProviderChange()" style="display: flex;"/>
|
||||
<SingleSelect v-model="provider" :options="RELAY_PROVIDERS" option-key="provider" option-label="name" @select="onProviderChange()" style="display: flex;"/>
|
||||
|
||||
<!-- set max-width here until Dialog supports that -->
|
||||
<div v-if="providerSpfDoc" class="text-warning" style="margin: 10px 0; max-width: 600px" v-html="$t('email.outbound.mailRelay.spfDocInfo', { name: provider, spfDocsLink: providerSpfDoc })"></div>
|
||||
|
||||
<div class="text-danger" style="margin: 10px 0;" v-if="provider === 'noop'">{{ $t(domain === adminDomain ? 'email.outbound.noopAdminDomainWarning' : 'email.outbound.noopNonAdminDomainWarning') }}</div>
|
||||
<div class="error-label" style="margin: 10px 0;" v-if="provider === 'noop'">{{ $t(domain === adminDomain ? 'email.outbound.noopAdminDomainWarning' : 'email.outbound.noopNonAdminDomainWarning') }}</div>
|
||||
|
||||
<div class="text-danger" v-if="formError">{{ formError }}</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" v-if="usesExternalServer(provider)">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none"/>
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
|
||||
<input type="submit" style="display: none" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
|
||||
@@ -183,7 +192,7 @@ async function onSubmit() {
|
||||
<!-- Postmark, Sendgrid, SparkPost -->
|
||||
<FormGroup v-if="usesTokenAuth(provider)">
|
||||
<label for="serverApiTokenInput">{{ $t('email.outbound.mailRelay.apiTokenOrKey') }}</label>
|
||||
<TextInput id="serverApiTokenInput" v-model="serverApiToken" required />
|
||||
<MaskedInput id="serverApiTokenInput" v-model="serverApiToken" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- Other -->
|
||||
@@ -194,7 +203,7 @@ async function onSubmit() {
|
||||
|
||||
<FormGroup v-if="usesPasswordAuth(provider)">
|
||||
<label for="passwordInput">{{ $t('email.outbound.mailRelay.password') }}</label>
|
||||
<TextInput id="passwordInput" v-model="password" required />
|
||||
<MaskedInput id="passwordInput" v-model="password" required />
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -205,9 +214,11 @@ async function onSubmit() {
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.outbound.title') }} <sup><a href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-html="$t('email.outbound.description')"></div>
|
||||
<div>
|
||||
<span>{{ prettyRelayProviderName(currentProvider) }}</span> / <span v-html="$t('email.outbound.description')"></span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="display: flex; align-items: center;">
|
||||
<Button tool plain @click="onShowDialog()">{{ $t('main.dialog.edit') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
|
||||
@@ -109,20 +109,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<SettingsItem wrap>
|
||||
<div style="display: flex; align-items: center">
|
||||
<div style="display: flex; align-items: center; width: 100%">
|
||||
<div v-html="$t('emails.changeDomainDialog.description')"></div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
<form @submit.prevent="onSubmit()">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
<form @submit.prevent="onSubmit()" style="display: flex; align-items: center; width: 100%; justify-content: end;" autocomplete="off">
|
||||
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
|
||||
|
||||
<InputGroup>
|
||||
<TextInput v-model="subdomain" :disabled="busy"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('main.dialog.save') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
<InputGroup style="overflow: hidden;">
|
||||
<TextInput v-model="subdomain" :disabled="busy" style="width: 120px"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
|
||||
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('emails.changeDomainDialog.setAction') }}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</SettingsItem>
|
||||
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef, computed, inject } from 'vue';
|
||||
import { Dialog, Button, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import MailboxesModel from '../models/MailboxesModel.js';
|
||||
@@ -10,6 +10,7 @@ const props = defineProps([ 'apps', 'users', 'groups', 'domains' ]);
|
||||
|
||||
const mailboxesModel = MailboxesModel.create();
|
||||
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
@@ -18,7 +19,7 @@ const domain = ref('');
|
||||
const mailbox = ref(null);
|
||||
const aliases = ref([]);
|
||||
const ownerId = ref('');
|
||||
const usersAndGroups = ref([]);
|
||||
const usersAndGroupsAndApps = ref([]);
|
||||
const storageQuotaEnabled = ref(false);
|
||||
const storageQuotaTicks = [ 500*1000*1000, 5*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
|
||||
const storageQuota = ref(5*1000*1000*1000);
|
||||
@@ -34,7 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
name: '',
|
||||
domain: '@' + props.domains[0].domain,
|
||||
domain: domain.value,
|
||||
label: '@' + domain.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,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 = '';
|
||||
|
||||
@@ -78,32 +88,42 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(m = null) {
|
||||
m = m ? JSON.parse(JSON.stringify(m)) : null; // make a copy
|
||||
busy.value = false;
|
||||
formError.value = '';
|
||||
mailbox.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
ownerId.value = m ? m.ownerId : '';
|
||||
aliases.value = m ? JSON.parse(JSON.stringify(m.aliases)) : [];
|
||||
aliases.value = m ? m.aliases : [];
|
||||
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;
|
||||
usersAndGroups.value = props.users.concat(props.groups).concat(props.apps);
|
||||
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroups.value.forEach(u => {
|
||||
u.icon = u.name ? 'fa-solid fa-users' : (u.username ? 'fa-solid fa-user' : 'fa-solid fa-cube') ;
|
||||
u.name = u.name || u.username || u.label || u.fqdn;
|
||||
});
|
||||
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: '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.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.map(a => {
|
||||
return { ...a, icon: 'fa-solid fa-cube', name: a.label || a.fqdn };
|
||||
}));
|
||||
|
||||
domainList.value = props.domains.map(d => {
|
||||
return {
|
||||
@@ -114,6 +134,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -121,26 +143,25 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailbox ? $t('email.editMailboxDialog.title', { name: mailbox.name, domain: mailbox.domain }) : $t('email.addMailboxDialog.title')"
|
||||
: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 v-if="!mailbox">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<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>
|
||||
@@ -148,7 +169,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroups" :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')"/>
|
||||
@@ -173,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 } 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';
|
||||
|
||||
@@ -19,6 +19,11 @@ const membersText = ref('');
|
||||
const membersOnly = ref(false);
|
||||
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;
|
||||
@@ -57,12 +62,13 @@ async function onSubmit() {
|
||||
|
||||
defineExpose({
|
||||
async open(m = null) {
|
||||
m = m ? JSON.parse(JSON.stringify(m)) : null; // make a copy
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
mailinglist.value = m;
|
||||
|
||||
name.value = m ? m.name : '';
|
||||
domain.value = m ? m.domain : props.domains[0].domain;
|
||||
domain.value = m ? m.domain : dashboardDomain.value;
|
||||
membersText.value = m ? m.members.join('\n') : '';
|
||||
membersOnly.value = m ? m.membersOnly : false;
|
||||
active.value = m ? m.active : true;
|
||||
@@ -82,7 +88,8 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="mailinglist ? $t('email.editMailinglistDialog.title', { name: mailinglist.name, domain: mailinglist.domain }) : $t('email.addMailinglistDialog.title')"
|
||||
: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 !== ''"
|
||||
@@ -98,17 +105,17 @@ defineExpose({
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
|
||||
|
||||
<FormGroup v-if="!mailinglist">
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
|
||||
<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,29 +1,13 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { FormGroup, MultiSelect } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
import GroupsModel from '../models/GroupsModel.js';
|
||||
|
||||
defineProps(['hasFtp']);
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
defineProps(['hasFtp', 'users', 'groups']);
|
||||
|
||||
const accessRestriction = defineModel('acl');
|
||||
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
onMounted(async () => {
|
||||
let [error, result] = await usersModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.username = u.username || u.email);
|
||||
users.value = result;
|
||||
|
||||
[error, result] = await groupsModel.list();
|
||||
if (error) return console.error(error);
|
||||
groups.value = result;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -32,7 +16,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.accessControl.operators.title') }} <sup><a href="https://docs.cloudron.io/apps/#operators" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
<div description>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="margin-top: 10px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
|
||||
+18
-14
@@ -1,8 +1,8 @@
|
||||
<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';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -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>
|
||||
@@ -5,8 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import { copyToClipboard } from '@cloudron/pankow/utils';
|
||||
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
|
||||
import UsersModel from '../models/UsersModel.js';
|
||||
|
||||
const usersModel = UsersModel.create();
|
||||
@@ -34,13 +33,9 @@ async function onSend() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
function onCopyLink() {
|
||||
copyToClipboard(resetLink.value);
|
||||
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(u) {
|
||||
u = JSON.parse(JSON.stringify(u)); // make a copy
|
||||
user.value = u;
|
||||
formError.value = '';
|
||||
email.value = u.fallbackEmail || u.email;
|
||||
@@ -64,7 +59,7 @@ defineExpose({
|
||||
<label class="control-label">{{ $t('users.passwordResetDialog.descriptionLink') }}</label>
|
||||
<InputGroup>
|
||||
<TextInput id="passwordResetLinkInput" style="flex-grow: 1;" v-model="resetLink" readonly/>
|
||||
<Button tool @click="onCopyLink()" icon="fa fa-clipboard" />
|
||||
<ClipboardButton :value="resetLink" />
|
||||
</InputGroup>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
@@ -14,12 +14,20 @@ const udpPorts = defineModel('udp');
|
||||
<div v-for="ports in [ tcpPorts, udpPorts ]" :key="ports">
|
||||
<FormGroup v-for="(port, key) in ports" :key="key" style="margin-top: 10px;">
|
||||
<Checkbox :label="port.title" v-model="port.enabled" />
|
||||
<small>{{ port.description + '.' + (port.portCount >=1 ? (port.portCount + ' ports. ') : '') }}</small>
|
||||
<small>{{ port.description + (port.portCount > 1 ? ('. ' + port.portCount + ' ports. ') : '') }}</small>
|
||||
<small v-show="port.readOnly">{{ $t('appstore.installDialog.portReadOnly') }}</small>
|
||||
<small class="has-error" v-if="error.port === port.value">Port already taken {{ port }}</small>
|
||||
<NumberInput v-model="port.value" :disabled="!port.enabled" :min="1"/>
|
||||
<div class="warning-label" v-show="domainProvider === 'cloudflare'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
|
||||
<div class="warning-label" v-show="domainProvider === 'manual'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
|
||||
<!-- 32768–60999 (inclusive) is the usual ephemeral port range -->
|
||||
<div class="warning-label" v-if="[port.value, port.value + port.portCount].some(p => p >= 32768 && p <= 60999)">{{ $t('appstore.installDialog.ephemeralPortWarning') }} <sup><a href="https://docs.cloudron.io/apps/#port-bindings" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div class="warning-label" v-if="domainProvider === 'cloudflare'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pankow-form-group small {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
+18
-12
@@ -1,9 +1,9 @@
|
||||
<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';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -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">
|
||||
<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>
|
||||
@@ -11,6 +11,10 @@ defineProps({
|
||||
type: String,
|
||||
default: `${API_ORIGIN}/api/v1/cloudron/avatar`,
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -20,11 +24,13 @@ defineProps({
|
||||
<div class="public-page-layout-root">
|
||||
<div class="public-page-layout-left pankow-no-mobile" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="public-page-layout-right">
|
||||
<div class="public-page-layout-mobile-logo">
|
||||
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
|
||||
<div class="cloudron-name">{{ cloudronName }}</div>
|
||||
</div>
|
||||
<div class="public-page-layout-right-slot">
|
||||
<slot></slot>
|
||||
@@ -88,11 +94,25 @@ defineProps({
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.public-page-layout-left img {
|
||||
margin-bottom: 20%;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.public-page-layout-left {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.public-page-layout-left .cloudron-avatar {
|
||||
margin-bottom: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.public-page-layout-left .cloudron-name {
|
||||
font-family: var(--font-family--header);
|
||||
font-weight: 400;
|
||||
font-size: 1.75em;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
flex-basis: 70%;
|
||||
display: flex;
|
||||
@@ -121,6 +141,7 @@ defineProps({
|
||||
@media (max-width: 576px) {
|
||||
.public-page-layout-mobile-logo {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
@@ -133,11 +154,25 @@ defineProps({
|
||||
padding-right: 20px;
|
||||
justify-content: start;
|
||||
flex-basis: unset;
|
||||
gap: 20px;
|
||||
text-align: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.public-page-layout-right-slot {
|
||||
max-width: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cloudron-avatar {
|
||||
border-radius: 10px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.actions {
|
||||
align-items: unset;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { inject } from 'vue';
|
||||
import { inject, useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
const mobileFilterBar = useTemplateRef('mobileFilterBar');
|
||||
|
||||
const isMobile = ref(false);
|
||||
|
||||
defineProps({
|
||||
title: String,
|
||||
@@ -16,18 +20,34 @@ function onTitleBadge() {
|
||||
subscriptionRequiredDialog.value.open();
|
||||
}
|
||||
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkForMobile();
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section">
|
||||
<h2 class="section-header">
|
||||
<div>
|
||||
<slot name="header-title">{{ title }}</slot>
|
||||
<slot name="header-title-extra"></slot>
|
||||
<div class="section-header-title-text">
|
||||
<slot name="header-title">{{ title }}</slot>
|
||||
<slot name="header-title-extra"></slot>
|
||||
</div>
|
||||
<div class="section-header-title-badge" v-if="titleBadge" @click="onTitleBadge()">{{ titleBadge }}</div>
|
||||
</div>
|
||||
<div><slot name="header-buttons"></slot></div>
|
||||
<div><Teleport :disabled="!isMobile" :to="mobileFilterBar"><slot name="filter-bar"></slot></Teleport><slot name="header-buttons"></slot></div>
|
||||
</h2>
|
||||
<div class="section-mobile-filter-bar" v-show="isMobile && $slots['filter-bar']" ref="mobileFilterBar"></div>
|
||||
<hr class="section-divider"/>
|
||||
<div class="section-body">
|
||||
<slot></slot>
|
||||
@@ -59,6 +79,11 @@ function onTitleBadge() {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section-header-title-text {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
@@ -80,6 +105,7 @@ function onTitleBadge() {
|
||||
margin-bottom: 15px;
|
||||
padding: 10px 15px;
|
||||
padding-bottom: 25px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header-title-badge {
|
||||
@@ -94,4 +120,11 @@ function onTitleBadge() {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-mobile-filter-bar {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 15px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -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;
|
||||
@@ -104,17 +114,15 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PublicPageLayout :footerHtml="footer">
|
||||
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
|
||||
<div>
|
||||
<div v-if="mode === MODE.SETUP">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<div>{{ $t('setupAccount.description') }}</div>
|
||||
<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;"/>
|
||||
@@ -147,29 +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">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<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">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<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">
|
||||
<small>{{ $t('setupAccount.welcomeTo') }}</small>
|
||||
<h1>{{ cloudronName }}</h1>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.success.title') }}</h3>
|
||||
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user