Compare commits
950 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -2980,3 +2980,209 @@
|
||||
* 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
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
#!/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 oidcServer from './src/oidcserver.js';
|
||||
import paths from './src/paths.js';
|
||||
import proxyAuth from './src/proxyauth.js';
|
||||
import safe from '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 +55,43 @@ 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 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 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,37 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Confirm Device</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
code { font-size: 2em; }
|
||||
button[autofocus] { width: 100%; display: block; margin-bottom: 10px; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
|
||||
button[autofocus]:hover { background-color: #357ae8; }
|
||||
button[name=abort] { background: none; border: none; padding: 0; font: inherit; cursor: pointer; color: #666; opacity: .6; }
|
||||
.help { width: 100%; font-size: 12px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Confirm Device</h1>
|
||||
<p>
|
||||
<strong><%= clientName %></strong>
|
||||
<br/><br/>
|
||||
The following code should be displayed on your device<br/><br/>
|
||||
<code><%= userCode %></code>
|
||||
<br/><br/>
|
||||
<small>If you did not initiate this action or the code does not match, please close this window or click abort.</small>
|
||||
</p>
|
||||
<%- form %>
|
||||
<button autofocus type="submit" form="op.deviceConfirmForm">Continue</button>
|
||||
<div class="help">
|
||||
<button type="submit" form="op.deviceConfirmForm" value="yes" name="abort">[ Abort ]</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Sign-in</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
p.red { color: #d50000; }
|
||||
input[type=text] { height: 44px; font-size: 16px; width: 100%; margin-bottom: 10px; background: #fff; border: 1px solid #d9d9d9; border-top: 1px solid silver; padding: 0 8px; box-sizing: border-box; text-transform: uppercase; text-align: center; }
|
||||
input[type=text]::placeholder { text-transform: none; }
|
||||
[type=submit] { width: 100%; display: block; margin-bottom: 10px; text-align: center; font-size: 14px; font-weight: 700; height: 36px; padding: 0 8px; border: 0; color: #fff; background-color: #4d90fe; cursor: pointer; }
|
||||
[type=submit]:hover { background-color: #357ae8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Sign-in</h1>
|
||||
<%- message %>
|
||||
<%- form %>
|
||||
<button type="submit" form="op.deviceInputForm">Continue</button>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Success</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; margin-top: 25px; margin-bottom: 25px; }
|
||||
h1, h1+p { font-weight: 100; text-align: center; }
|
||||
.container { padding: 0 40px 10px; width: 274px; background-color: #f7f7f7; margin: 0 auto 10px; border-radius: 2px; box-shadow: 0 2px 2px rgba(0,0,0,.3); overflow: hidden; }
|
||||
h1 { font-size: 2.3em; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Success</h1>
|
||||
<p>Your device has been authorized. You can close this window.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -4,12 +4,13 @@
|
||||
<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: iconUrl,
|
||||
name: name,
|
||||
errorMessage: errorMessage,
|
||||
footer: footer,
|
||||
language: 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
+1696
-2616
File diff suppressed because it is too large
Load Diff
+17
-16
@@ -7,26 +7,27 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.1",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@cloudron/pankow": "^4.1.5",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@xterm/addon-attach": "^0.12.0",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"anser": "^2.3.5",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.37.0",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"marked": "^16.4.0",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"marked": "^17.0.4",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.1.9",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue-router": "^4.5.1"
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-singlefile": "^2.3.2",
|
||||
"vue": "^3.5.30",
|
||||
"vue-i18n": "^11.3.0",
|
||||
"vue-router": "^5.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
+605
-1030
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>
|
||||
|
||||
+247
-208
@@ -1,17 +1,24 @@
|
||||
<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 Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
import OfflineOverlay from './components/OfflineOverlay.vue';
|
||||
import SideBar from './components/SideBar.vue';
|
||||
import AppsView from './views/AppsView.vue';
|
||||
import AppConfigureView from './views/AppConfigureView.vue';
|
||||
import AppearanceView from './views/AppearanceView.vue';
|
||||
@@ -28,6 +35,7 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
|
||||
import EmailEventlogView from './views/EmailEventlogView.vue';
|
||||
import EventlogView from './views/EventlogView.vue';
|
||||
import NetworkView from './views/NetworkView.vue';
|
||||
import NotificationsView from './views/NotificationsView.vue';
|
||||
import ProfileView from './views/ProfileView.vue';
|
||||
import ServicesView from './views/ServicesView.vue';
|
||||
import SystemSettingsView from './views/SystemSettingsView.vue';
|
||||
@@ -58,6 +66,7 @@ const VIEWS = Object.freeze({
|
||||
EMAIL_EVENTLOG: '#/email-eventlog',
|
||||
SERVER: '#/server',
|
||||
NETWORK: '#/network',
|
||||
NOTIFICATIONS: '#/notifications',
|
||||
PROFILE: '#/profile',
|
||||
SERVICES: '#/services',
|
||||
SYSTEM_SETTINGS: '#/system-settings',
|
||||
@@ -72,11 +81,175 @@ const VIEWS = Object.freeze({
|
||||
VOLUMES: '#/volumes',
|
||||
});
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
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,
|
||||
}]);
|
||||
|
||||
function onOnline() {
|
||||
ready.value = true;
|
||||
}
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
// network error, request killed by browser
|
||||
@@ -103,12 +276,15 @@ fetcher.globalOptions.errorHook = (error) => {
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
const notificationModel = NotificationsModel.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: {},
|
||||
});
|
||||
@@ -116,24 +292,8 @@ const config = ref({});
|
||||
const avatarUrl = ref('');
|
||||
const features = ref({});
|
||||
|
||||
function onSidebarClose() {
|
||||
sidebar.value.close();
|
||||
}
|
||||
|
||||
const SIDEBAR_GROUPS = Object.freeze({
|
||||
BACKUP: 'backup',
|
||||
EMAIL: 'email',
|
||||
SYSTEM: 'system',
|
||||
USERS: 'users'
|
||||
});
|
||||
|
||||
const activeSidebarGroups = ref({});
|
||||
function onToggleGroup(group) {
|
||||
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const v = location.hash;
|
||||
const v = window.location.hash.split('?')[0];
|
||||
|
||||
if (v === VIEWS.APPS) {
|
||||
view.value = VIEWS.APPS;
|
||||
@@ -165,6 +325,8 @@ function onHashChange() {
|
||||
view.value = VIEWS.EMAIL_EVENTLOG;
|
||||
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.SERVER;
|
||||
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NOTIFICATIONS;
|
||||
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
|
||||
view.value = VIEWS.NETWORK;
|
||||
} else if (v === VIEWS.PROFILE) {
|
||||
@@ -212,15 +374,45 @@ 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 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);
|
||||
@@ -228,38 +420,53 @@ 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();
|
||||
|
||||
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>
|
||||
@@ -269,74 +476,14 @@ onMounted(async () => {
|
||||
<Notification />
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
|
||||
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
|
||||
<RequestErrorDialog/>
|
||||
<InputDialog ref="inputDialog"/>
|
||||
|
||||
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
|
||||
<SideBar v-if="profile.isAtLeastUserManager" ref="sidebar">
|
||||
<a href="#/" class="sidebar-logo" @click="onSidebarClose()">
|
||||
<img :src="avatarUrl" :alt="(config.cloudronName || 'Cloudron') + ' icon'" width="40" height="40"/> {{ config.cloudronName || 'Cloudron' }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPS || view === VIEWS.APP }" :href="VIEWS.APPS" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPSTORE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPSTORE" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
|
||||
<hr/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOMAINS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.DOMAINS" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup(SIDEBAR_GROUPS.USERS)"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.USERS] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.USERS]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USERS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.USERS" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.GROUPS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.GROUPS" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.LDAP }" :href="VIEWS.LDAP" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> {{ $t('ldap.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.OPENID }" :href="VIEWS.OPENID" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> {{ $t('oidc.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USER_DIRECTORY_SETTINGS }" :href="VIEWS.USER_DIRECTORY_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup(SIDEBAR_GROUPS.EMAIL)"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.EMAIL] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.EMAIL]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILBOXES }" :href="VIEWS.MAILBOXES" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.MAILINGLISTS }" :href="VIEWS.MAILINGLISTS" @click="onSidebarClose()"><i class="fa fa-fw-solid fa-envelopes-bulk"></i> {{ $t('email.incoming.mailinglists.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_EVENTLOG }" :href="VIEWS.EMAIL_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_SETTINGS }" :href="VIEWS.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: view === VIEWS.NETWORK }" v-show="profile.isAtLeastAdmin" :href="VIEWS.NETWORK" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.VOLUMES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.VOLUMES" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.BACKUP)"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.BACKUP] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.BACKUP]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.BACKUP_SITES }" :href="VIEWS.BACKUP_SITES" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APP_ARCHIVE }" :href="VIEWS.APP_ARCHIVE" @click="onSidebarClose()"><i class="fa fa-fw fa-grip"></i> {{ $t('backups.archives.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPEARANCE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPEARANCE" @click="onSidebarClose()"><i class="fa fa-pen-ruler fa-fw"></i> {{ $t('appearance.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.SYSTEM)"><i class="fa fa-server fa-fw"></i> {{ $t('system.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> {{ $t('docker.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVICES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVICES" @click="onSidebarClose()"><i class="fa fa-diagram-project fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_EVENTLOG }" :href="VIEWS.SYSTEM_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_UPDATE }" :href="VIEWS.SYSTEM_UPDATE" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_SETTINGS }" :href="VIEWS.SYSTEM_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('system.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<hr v-show="profile.isAtLeastAdmin"/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVER }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVER" @click="onSidebarClose()"><i class="fa fa-microchip fa-fw"></i> {{ $t('server.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.CLOUDRON_ACCOUNT }" v-show="profile.isAtLeastOwner" :href="VIEWS.CLOUDRON_ACCOUNT" @click="onSidebarClose()"><i class="fa fa-crown fa-fw"></i> {{ $t('settings.appstoreAccount.title') }}</a>
|
||||
</div>
|
||||
</SideBar>
|
||||
<SideBar v-if="profile.isAtLeastUserManager" :items="menuItems" :cloudron-name="config.cloudronName" :cloudron-avatar-url="avatarUrl"/>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<Headerbar :config="config" :subscription="subscription"/>
|
||||
<Headerbar :config="config" :subscription="subscription" :notification-count="notificationCount"/>
|
||||
|
||||
<div style="display: flex; justify-content: center; overflow: auto; flex-grow: 1; padding: 0; margin: 0 10px; position: relative;">
|
||||
<KeepAlive>
|
||||
@@ -358,6 +505,7 @@ onMounted(async () => {
|
||||
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
|
||||
<ServerView v-else-if="view === VIEWS.SERVER" />
|
||||
<NetworkView v-else-if="view === VIEWS.NETWORK" />
|
||||
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
|
||||
<ProfileView v-else-if="view === VIEWS.PROFILE" />
|
||||
<ServicesView v-else-if="view === VIEWS.SERVICES" />
|
||||
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
|
||||
@@ -374,112 +522,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>
|
||||
@@ -5,10 +5,11 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import TokensModel from '../models/TokensModel.js';
|
||||
|
||||
@@ -27,6 +28,15 @@ const columns = {
|
||||
label: t('profile.apiTokens.name'),
|
||||
sort: true
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
lastUsedTime: {
|
||||
label: t('profile.apiTokens.lastUsed'),
|
||||
sort(a, b) {
|
||||
@@ -35,36 +45,28 @@ const columns = {
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
actions: {
|
||||
width: '55px',
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(apiToken, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(apiToken) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRevokeToken.bind(null, apiToken),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!tokenName.value) return false;
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) return false;
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiTokens() {
|
||||
const [error, tokens] = await tokensModel.list();
|
||||
@@ -74,7 +76,7 @@ async function refreshApiTokens() {
|
||||
}
|
||||
|
||||
async function onSubmitAddApiToken(){
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
const scope = { '*': tokenScope.value };
|
||||
const allowedIpRanges = tokenAllowedIpRanges.value;
|
||||
@@ -96,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;
|
||||
@@ -123,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()"
|
||||
@@ -138,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/>
|
||||
@@ -154,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>
|
||||
@@ -179,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,7 +295,7 @@ defineExpose({
|
||||
|
||||
<Dialog ref="dialog" :title="$t('app.importBackupDialog.title')"
|
||||
:confirm-label="$t('app.importBackupDialog.importAction')"
|
||||
:confirm-active="!busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@@ -224,7 +303,10 @@ defineExpose({
|
||||
@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"/>
|
||||
@@ -233,14 +315,14 @@ defineExpose({
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<!-- remotePath contains the prefix as well -->
|
||||
<FormGroup>
|
||||
<label for="inputRemotePath">{{ $t('app.importBackupDialog.remotePath') }} <sup><a href="https://docs.cloudron.io/backups/#import-app-backup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="inputRemotePath" v-model="remotePath" required />
|
||||
<TextInput id="inputRemotePath" v-model="fullPath" required />
|
||||
</FormGroup>
|
||||
|
||||
<BackupProviderForm ref="form"
|
||||
|
||||
@@ -1,56 +1,67 @@
|
||||
<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, 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 DomainsModel from '../models/DomainsModel.js';
|
||||
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.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 domainsModel = DomainsModel.create();
|
||||
const dashboardModel = DashboardModel.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);
|
||||
async function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (location.value && !isValidDomain(location.value + '.' + domain.value)) 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 (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
|
||||
|
||||
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);
|
||||
@@ -63,7 +74,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
|
||||
@@ -77,6 +90,8 @@ const udpPorts = ref({});
|
||||
const secondaryDomains = ref({});
|
||||
const upstreamUri = ref('');
|
||||
const needsOverwriteDns = ref(false);
|
||||
const users = ref([]);
|
||||
const groups = ref([]);
|
||||
|
||||
function onDomainChange() {
|
||||
const tmp = domains.value.find(d => d.domain === domain.value);
|
||||
@@ -84,6 +99,8 @@ function onDomainChange() {
|
||||
}
|
||||
|
||||
async function onSubmit(overwriteDns) {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
@@ -141,12 +158,12 @@ async function onSubmit(overwriteDns) {
|
||||
|
||||
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
|
||||
|
||||
const [error, result] = await appsModel.install(manifest.value, config);
|
||||
const [error, result] = await appsModel.install(packageData.value, config);
|
||||
|
||||
if (!error) {
|
||||
dialog.value.close();
|
||||
localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = '/#/apps';
|
||||
if (manifest.value.postInstallMessage) localStorage['confirmPostInstall_' + result.id] = true;
|
||||
return window.location.href = `/#/app/${result.id}/info`;
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
@@ -155,9 +172,8 @@ async function onSubmit(overwriteDns) {
|
||||
formError.value.port = match ? parseInt(match[1]) : null;
|
||||
} else if (error.status === 409 && error.body.message.indexOf('primary location') !== -1) {
|
||||
formError.value.location = error.body.message;
|
||||
} else if (error.status === 412) {
|
||||
formError.value.generic = error.body.message;
|
||||
} else {
|
||||
formError.value.generic = error.body?.message || `Error installing app. Status code: ${error.status} . ${error.body}`;
|
||||
console.error('Failed to install:', error);
|
||||
}
|
||||
}
|
||||
@@ -167,10 +183,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');
|
||||
@@ -192,12 +212,14 @@ 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: [] };
|
||||
@@ -212,10 +234,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) {
|
||||
@@ -227,15 +249,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() {
|
||||
@@ -246,18 +268,20 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="true" style="width: unset; min-width: min(450px, 95%)">
|
||||
<div class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="step !== STEP.LOADING" style="width: unset;" :style="{ 'min-width': step !== STEP.LOADING ? 'min(450px, 95%)' : 'unset' }">
|
||||
<div v-if="step === STEP.LOADING" class="app-install-dialog-body">
|
||||
<Spinner class="pankow-spinner-large"/>
|
||||
</div>
|
||||
<div v-else class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<div class="app-install-header">
|
||||
<div class="summary" v-if="app.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
|
||||
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
|
||||
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
|
||||
<div class="summary" v-if="packageData.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>{{ manifest.title }}</div>
|
||||
<div><a :href="manifest.website" target="_blank">{{ manifest.title }}</a> {{ packageData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ packageData.manifest.version }}</div>
|
||||
<div v-if="packageData.versionsUrl"><a :href="packageData.manifest.packagerUrl" target="_blank">{{ packageData.manifest.packagerName }}</a></div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(packageData.creationDate) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
|
||||
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
|
||||
</div>
|
||||
<img class="icon pankow-no-mobile" :src="app.iconUrl" />
|
||||
<img class="icon pankow-no-mobile" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
|
||||
</div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="step === STEP.DETAILS">
|
||||
@@ -275,15 +299,15 @@ defineExpose({
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
<div class="error-label" v-if="formError.dnsExists">{{ formError.dnsExists }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit(false)" autocomplete="off">
|
||||
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit(false)" 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"/>
|
||||
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10" required/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
|
||||
<div class="error-label" v-if="formError.location">{{ formError.location }}</div>
|
||||
@@ -294,21 +318,21 @@ defineExpose({
|
||||
<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" />
|
||||
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" 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"/>
|
||||
|
||||
<div class="bottom-button-bar">
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
<Button v-if="needsOverwriteDns" danger @click="onSubmit(true)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }} and overwrite DNS</Button>
|
||||
<Button v-else @click="onSubmit(false)" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid" :loading="busy">Install {{ manifest.title }}</Button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -336,7 +360,6 @@ defineExpose({
|
||||
|
||||
.app-install-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, DateTimeInput, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import AppPasswordsModel from '../models/AppPasswordsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -29,27 +29,32 @@ const columns = {
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
label: t('main.table.created'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
expiresAt: {
|
||||
label: t('profile.appPasswords.expires'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
if (!b) return -1;
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(appPassword, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(appPassword) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRemove.bind(null, appPassword),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
// new dialog props
|
||||
@@ -57,61 +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();
|
||||
|
||||
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;
|
||||
@@ -156,32 +185,41 @@ 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>
|
||||
@@ -205,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,7 +191,7 @@ 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="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@@ -207,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>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { inject, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup } from '@cloudron/pankow';
|
||||
import ApplinkDialog from './ApplinkDialog.vue';
|
||||
import Section from './Section.vue';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
|
||||
function onAddExternalLink() {
|
||||
applinkDialog.value.open();
|
||||
}
|
||||
|
||||
function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
|
||||
<Section :title="$t('dashboard.title')">
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('externallinks.label') }}</label>
|
||||
<div>{{ $t('externallinks.description') }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; position: relative; align-items: center">
|
||||
<Button tool plain @click="onAddExternalLink()" :disabled="!features.branding">{{ $t('main.action.add') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -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();
|
||||
});
|
||||
@@ -113,25 +128,25 @@ onMounted(async () => {
|
||||
|
||||
<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') }} ({{ provider }})</label>
|
||||
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
|
||||
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
|
||||
</FormGroup>
|
||||
|
||||
@@ -140,13 +155,13 @@ onMounted(async () => {
|
||||
|
||||
<!-- 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>
|
||||
|
||||
@@ -178,19 +193,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')"/>
|
||||
@@ -200,12 +215,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' ||
|
||||
@@ -236,7 +253,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)">
|
||||
@@ -253,7 +271,8 @@ onMounted(async () => {
|
||||
<input type="file" id="gcsKeyFileInput" style="display:none" accept="application/json, text/json" @change="onGcsKeyChange"/>
|
||||
<label for="gcsKeyInput">{{ $t('backups.configureBackupStorage.gcsServiceKey') }}{{ providerConfig.projectId ? ` - project: ${providerConfig.projectId}` : '' }}</label>
|
||||
<InputGroup>
|
||||
<TextInput readonly required style="flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service Account Key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<input style="display: none" :value="providerConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; flex-grow: 1" v-model="providerConfig.credentials.client_email" placeholder="Service account key" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
<Button tool icon="fa fa-upload" onclick="document.getElementById('gcsKeyFileInput').click();"/>
|
||||
</InputGroup>
|
||||
<div class="error-label" v-show="gcsFileParseError">{{ gcsFileParseError }}</div>
|
||||
|
||||
@@ -16,7 +16,6 @@ const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const step = ref('storage');
|
||||
const newSiteId = ref('');
|
||||
const name = ref('');
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -247,7 +255,7 @@ defineExpose({
|
||||
encryptionPasswordHint.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
limits.value = {};
|
||||
includeExclude.value = 'everything';
|
||||
includeExclude.value = '';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
@@ -282,6 +290,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -291,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"/>
|
||||
|
||||
@@ -306,10 +316,10 @@ defineExpose({
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div>
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<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>
|
||||
@@ -370,7 +380,7 @@ defineExpose({
|
||||
|
||||
<div style="display: flex; gap: 6px; align-items: end;">
|
||||
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
|
||||
<Button primary :disabled="busy" :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>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watch } from 'vue';
|
||||
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
|
||||
import { prettyBinarySize } from '@cloudron/pankow/utils';
|
||||
import { s3like, mountlike, regionName } from '../utils.js';
|
||||
import { s3like, mountlike } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -38,6 +38,11 @@ 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;
|
||||
|
||||
@@ -200,15 +205,7 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.provider && site.config">
|
||||
<label><i v-if="site.encrypted" class="fa-solid fa-lock"></i> Storage: <b>{{ site.provider }} ({{ site.format }}) </b></label>
|
||||
<div>
|
||||
<span v-if="site.provider === 'filesystem'">{{ site.config.backupDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'disk' || site.provider === 'ext4' || site.provider === 'xfs' || site.provider === 'mountpoint'">{{ site.config.mountOptions.diskPath || site.config.mountPoint }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'sshfs'">{{ site.config.mountOptions.host }}:{{ site.config.mountOptions.remoteDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 's3'">{{ site.config.region + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'minio'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'gcs'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else>{{ regionName(site.provider, site.config.endpoint) + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
</div>
|
||||
<div>{{ site.locationLabel }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
@@ -249,13 +246,13 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
|
||||
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
|
||||
<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>
|
||||
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
|
||||
<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>
|
||||
@@ -269,21 +266,19 @@ defineExpose({
|
||||
|
||||
<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>
|
||||
<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 class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
|
||||
<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 class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
|
||||
<span v-show="provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -35,8 +35,20 @@ async function onSubmit() {
|
||||
if (includeExclude.value === 'everything') {
|
||||
contents = null;
|
||||
} else if (includeExclude.value === 'exclude') {
|
||||
if (contentExclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Exclude at least one content item or select Everything';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { exclude: contentExclude.value };
|
||||
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
|
||||
} else if (includeExclude.value === 'include') {
|
||||
if (contentInclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Include at least one content item';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
@@ -60,6 +72,9 @@ defineExpose({
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
includeExclude.value = 'everything';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
|
||||
@@ -68,7 +83,7 @@ defineExpose({
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'Platform',
|
||||
label: 'System & email',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
@@ -86,8 +101,6 @@ defineExpose({
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
} else {
|
||||
includeExclude.value = 'everything';
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -109,21 +122,24 @@ defineExpose({
|
||||
>
|
||||
<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"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
|
||||
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
|
||||
<div class="error-label" v-if="formError.includeExclude">{{ formError.includeExclude }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
+29
-30
@@ -1,24 +1,24 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { Checkbox, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, parseSchedule } from '../utils.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
|
||||
const id = ref('');
|
||||
const site = ref({});
|
||||
const busy = ref(false);
|
||||
const formError = ref('');
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const scheduleEnabled = ref(false);
|
||||
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 !!days.value.length && !!hours.value.length;
|
||||
return scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -27,7 +27,7 @@ async function onSubmit() {
|
||||
busy.value = true;
|
||||
|
||||
let schedule;
|
||||
if (scheduleEnabled.value) {
|
||||
if (scheduleType.value === 'pattern') {
|
||||
let daysPattern;
|
||||
if (days.value.length === 7) daysPattern = '*';
|
||||
else daysPattern = days.value;
|
||||
@@ -41,7 +41,7 @@ async function onSubmit() {
|
||||
schedule = 'never';
|
||||
}
|
||||
|
||||
let [error] = await backupSitesModel.setSchedule(id.value, schedule);
|
||||
let [error] = await backupSitesModel.setSchedule(site.value.id, schedule);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -49,7 +49,7 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return x.name === configureRetention.value; });
|
||||
[error] = await backupSitesModel.setRetention(id.value, selectedRetention.id);
|
||||
[error] = await backupSitesModel.setRetention(site.value.id, selectedRetention.id);
|
||||
if (error) {
|
||||
busy.value = false;
|
||||
formError.value = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,29 +63,24 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(site) {
|
||||
id.value = site.id;
|
||||
async open(s) {
|
||||
site.value = s;
|
||||
busy.value = false;
|
||||
formError.value = false;
|
||||
days.value = [];
|
||||
hours.value = [];
|
||||
|
||||
const currentRetentionString = JSON.stringify(site.retention);
|
||||
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.schedule === 'never') {
|
||||
scheduleEnabled.value = false;
|
||||
if (site.value.schedule === 'never') {
|
||||
scheduleType.value = 'never';
|
||||
} else {
|
||||
scheduleEnabled.value = true;
|
||||
|
||||
const tmp = site.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
const tmpDays = tmp[5].split(',');
|
||||
|
||||
if (tmpDays[0] === '*') days.value = cronDays.map((day) => { return day.id; });
|
||||
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
|
||||
|
||||
if (tmpHours[0] === '*') hours.value = cronHours.map(h => h.id);
|
||||
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
|
||||
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();
|
||||
@@ -105,18 +100,22 @@ defineExpose({
|
||||
: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') }}</label>
|
||||
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')"></div>
|
||||
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule.title') }}</label>
|
||||
<div description v-html="$t('backups.configureBackupSchedule.schedule.description')"></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="cronDays" option-key="id" option-label="name"></MultiSelect></div>
|
||||
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></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>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function onNameSave(newName) {
|
||||
|
||||
const [error] = await brandingModel.setName(newName);
|
||||
savingName.value = false;
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
name.value = newName;
|
||||
}
|
||||
@@ -77,7 +77,7 @@ onMounted(async () => {
|
||||
<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"/>
|
||||
<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;">
|
||||
@@ -87,7 +87,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave"/>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<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 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
|
||||
}
|
||||
});
|
||||
|
||||
</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" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, inject } from 'vue';
|
||||
import { Button, ProgressBar, SingleSelect, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
@@ -14,6 +18,8 @@ const taskModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
|
||||
const inputDialog = inject('inputDialog');
|
||||
|
||||
const domains = ref([]);
|
||||
const formError = ref('');
|
||||
const originalDomain = ref('');
|
||||
@@ -64,6 +70,16 @@ async function refreshTasks() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
const confirm = await inputDialog.value.confirm({
|
||||
title: t('domains.changeDashboardDomain.confirmTitle'),
|
||||
message: t('domains.changeDashboardDomain.confirmMessage'),
|
||||
confirmStyle: 'danger',
|
||||
confirmLabel: t('main.dialog.yes'),
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
if (!confirm) return;
|
||||
|
||||
formError.value = '';
|
||||
|
||||
lastTask.value.active = true;
|
||||
@@ -130,7 +146,7 @@ defineExpose({ updateDomains: selectCurrentDomain });
|
||||
|
||||
<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,21 +30,6 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.filesystems-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 15px;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: 300ms;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.filesystems-grid {
|
||||
grid-template-columns: 1fr; /* Single column on small screens */
|
||||
}
|
||||
}
|
||||
|
||||
.usage-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -90,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,25 +111,31 @@ 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; 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>
|
||||
@@ -173,6 +160,7 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background-color: var(--card-background);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.disk-item:focus,
|
||||
@@ -189,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;
|
||||
}
|
||||
|
||||
@@ -237,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>
|
||||
|
||||
@@ -18,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' },
|
||||
];
|
||||
|
||||
@@ -38,7 +37,7 @@ const password = ref('');
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -83,8 +82,8 @@ 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 && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
@@ -113,7 +112,7 @@ 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>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Checkbox, Button } 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';
|
||||
@@ -31,7 +31,7 @@ const dnsConfig = ref(DomainsModel.createEmptyConfig());
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -99,6 +99,10 @@ function onKeyFileChange() {
|
||||
keyFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open(d) {
|
||||
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
|
||||
@@ -131,10 +135,10 @@ 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')"
|
||||
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
reject-style="secondary"
|
||||
@@ -148,7 +152,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="domainInput">{{ $t('domains.domainDialog.domain') }}</label>
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing ? true : undefined" required />
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing" :required="!editing" />
|
||||
</FormGroup>
|
||||
|
||||
<DomainProviderForm v-model:provider="provider" v-model:dns-config="dnsConfig" v-model:tls-provider="tlsProvider" v-model:zone-name="zoneName" v-model:custom-nameservers="customNameservers" :domain="domain" :show-advanced="showAdvanced" />
|
||||
@@ -156,14 +160,14 @@ defineExpose({
|
||||
<div v-show="showAdvanced">
|
||||
<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>
|
||||
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<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" />
|
||||
|
||||
@@ -53,21 +53,12 @@ function needsPort80(dnsProvider, tlsProvider) {
|
||||
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
|
||||
}
|
||||
|
||||
function setDefaultTlsProvider(p) {
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}
|
||||
|
||||
function resetFields() {
|
||||
dnsConfig.value.accessKeyId = '';
|
||||
dnsConfig.value.accessKey = '';
|
||||
dnsConfig.value.accessToken = '';
|
||||
dnsConfig.value.apiKey = '';
|
||||
dnsConfig.value.apikey = '';
|
||||
dnsConfig.value.appKey = '';
|
||||
dnsConfig.value.appSecret = '';
|
||||
dnsConfig.value.apiPassword = '';
|
||||
dnsConfig.value.apiSecret = '';
|
||||
@@ -87,8 +78,14 @@ function resetFields() {
|
||||
}
|
||||
|
||||
function onProviderChange(p) {
|
||||
setDefaultTlsProvider(p);
|
||||
resetFields(p);
|
||||
resetFields();
|
||||
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}
|
||||
|
||||
const gcdnsFileParseError = ref('');
|
||||
@@ -130,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" required />
|
||||
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required @select="onProviderChange"/>
|
||||
</FormGroup>
|
||||
|
||||
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
|
||||
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
|
||||
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
|
||||
|
||||
<!-- Route53 -->
|
||||
<FormGroup v-if="provider === 'route53'">
|
||||
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
|
||||
@@ -148,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>
|
||||
@@ -259,7 +261,7 @@ function onGcdnsFileInputChange(event) {
|
||||
</FormGroup>
|
||||
|
||||
<!-- Hetzner -->
|
||||
<FormGroup v-if="provider === 'hetzner'">
|
||||
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
|
||||
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
|
||||
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
|
||||
</FormGroup>
|
||||
@@ -310,19 +312,16 @@ function onGcdnsFileInputChange(event) {
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label for="zoneNameInput">{{ $t('domains.domainDialog.zoneName') }} <sup><a href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="zoneNameInput" v-model="zoneName" />
|
||||
<small class="helper-text">{{ $t('domains.domainDialog.zoneNamePlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
<Checkbox v-if="showAdvanced" v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
|
||||
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label>Certificate Provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
|
||||
<label>Certificate provider <sup><a href="https://docs.cloudron.io/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" required/>
|
||||
</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>
|
||||
|
||||
</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,277 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, reactive, 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 = reactive([]);
|
||||
|
||||
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.length) filter.actions = actions.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, 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: 160px;">{{ $t('eventlog.time') }}</th>
|
||||
<th style="width: 15%;">{{ $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 style="white-space: nowrap;">{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
|
||||
<td class="eventlog-source">{{ eventlog.source }}</td>
|
||||
<td style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" v-html="eventlog.details"></td>
|
||||
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="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;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.active,
|
||||
.eventlog-table tbody tr:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.eventlog-source {
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.eventlog-details {
|
||||
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.eventlog-details pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
padding-left: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match {
|
||||
background-color: rgba(255, 193, 7, 0.15);
|
||||
}
|
||||
|
||||
.eventlog-table tbody tr.eventlog-match-current {
|
||||
background-color: rgba(255, 193, 7, 0.35);
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -49,7 +49,7 @@ const autoCreate = ref(false);
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -258,7 +258,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isFormValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
|
||||
@@ -40,17 +40,27 @@ function renderTooltip(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, labelColors } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
// datapoints are in sync with the indexing of body
|
||||
const { title, body, labelColors, dataPoints } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
if (body) {
|
||||
const titleLines = title || [];
|
||||
const bodyLines = body.map(item => item.lines);
|
||||
const bodyLines = body.map(item => { return { label: item.lines }; });
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
|
||||
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = labelColors[i];
|
||||
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
|
||||
// first amend the value so we know the dataPoints index, then sort and render
|
||||
bodyLines.forEach((body, i) => {
|
||||
body.value = dataPoints[i].parsed?.y || 0;
|
||||
body.color = labelColors[i].borderColor;
|
||||
});
|
||||
bodyLines.sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
});
|
||||
bodyLines.slice(0, 5).forEach(body => {
|
||||
innerHtml += `<div style="color: ${body.color}" class="graphs-tooltip-item">${body.label}</div>`;
|
||||
});
|
||||
|
||||
if (bodyLines.length > 5) innerHtml += '<div class="graphs-tooltip-item graphs-tooltip-ellipsis">⋯</div>';
|
||||
|
||||
tooltipElem.value.innerHTML = innerHtml;
|
||||
}
|
||||
@@ -200,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');
|
||||
}
|
||||
|
||||
@@ -338,13 +350,13 @@ defineExpose({
|
||||
.graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 10px;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@@ -367,8 +379,33 @@ defineExpose({
|
||||
border-right: 1px var(--pankow-color-primary) solid;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item {
|
||||
padding: 2px 0px;
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
background: var(--pankow-color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs-tooltip-title {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item:last-of-type {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-ellipsis {
|
||||
font-size: 9px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,9 @@ const group = ref(null);
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const name = ref('');
|
||||
const users = ref([]);
|
||||
const userIds = ref([]);
|
||||
const allUsers = ref([]);
|
||||
const apps = ref([]);
|
||||
const appIds = ref([]);
|
||||
const allApps = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -29,7 +29,7 @@ async function onSubmit() {
|
||||
formError.value = {};
|
||||
|
||||
if (group.value) {
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -37,7 +37,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
} else {
|
||||
const [error] = await groupsModel.add(name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.add(name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,13 +63,13 @@ defineExpose({
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.label = (u.username || u.email));
|
||||
allUsers.value = result;
|
||||
users.value = g ? g.userIds : [];
|
||||
userIds.value = g ? g.userIds : [];
|
||||
|
||||
[error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(a => a.label = (a.label || a.fqdn));
|
||||
allApps.value = result;
|
||||
apps.value = g ? g.appIds : [];
|
||||
appIds.value = g ? g.appIds : [];
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
@@ -79,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 !== ''"
|
||||
@@ -103,13 +103,14 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="usersInput">{{ $t('users.group.users') }}</label>
|
||||
<div v-if="group?.source"><span v-for="user of groupEdit.selectedUsers" :key="user.id"> {{ (user.username || user.email) }}</span></div>
|
||||
<MultiSelect v-else v-model="users" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
<!-- membership of external groups cannot be edited -->
|
||||
<div v-if="group?.source"><span v-for="userId of userIds" :key="userId" style="padding-right: 5px">{{ allUsers.find(u => u.id === userId)?.username || allUsers.find(u => u.id === userId)[userId]?.email }}</span></div>
|
||||
<MultiSelect v-else v-model="userIds" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="appsInput">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,13 +6,11 @@ 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', 'subscription', 'notificationCount']);
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -24,50 +22,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 +32,7 @@ function onSubscriptionRequired() {
|
||||
|
||||
const platformStatus = ref({
|
||||
message: '',
|
||||
isReady: true,
|
||||
state: '',
|
||||
});
|
||||
|
||||
let platformTimeoutId = 0;
|
||||
@@ -87,7 +42,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 +61,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 +90,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 +108,20 @@ 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>
|
||||
|
||||
<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 +136,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,40 +168,6 @@ 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 {
|
||||
background-color: var(--pankow-color-danger);
|
||||
color: white;
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -56,16 +56,17 @@ 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>
|
||||
|
||||
@@ -59,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"
|
||||
>
|
||||
@@ -68,6 +68,8 @@ 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, ClipboardButton, Checkbox, PasswordInput, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -19,17 +19,14 @@ const ldapUrl = ref('');
|
||||
const secret = ref('');
|
||||
const allowlist = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (enabled.value) {
|
||||
if (!secret.value) return false;
|
||||
if (!allowlist.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
editError.value = {};
|
||||
@@ -57,7 +54,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain) || domains[0];
|
||||
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
|
||||
|
||||
[error, result] = await userDirectoryModel.getExposedLdapConfig();
|
||||
if (error) return console.error(error);
|
||||
@@ -65,6 +62,8 @@ onMounted(async () => {
|
||||
enabled.value = result.enabled;
|
||||
secret.value = result.secret;
|
||||
allowlist.value = result.allowlist;
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -72,11 +71,10 @@ onMounted(async () => {
|
||||
<template>
|
||||
<Section :title="$t('users.exposedLdap.title')">
|
||||
<div>{{ $t('users.exposedLdap.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit" :disabled="busy || !isValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<Checkbox v-model="enabled" :label="$t('users.exposedLdap.enabled')" help-url="https://docs.cloudron.io/user-directory/#ldap-directory-server"/>
|
||||
|
||||
@@ -92,14 +90,15 @@ onMounted(async () => {
|
||||
<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 />
|
||||
<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" :placeholder="$t('users.exposedLdap.ipRestriction.placeholder')" rows="4" required></textarea>
|
||||
<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>
|
||||
@@ -108,6 +107,6 @@ onMounted(async () => {
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
|
||||
<br/>
|
||||
<Button :loading="busy" :disabled="!isValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
<Button :loading="busy" :disabled="!isFormValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -1,150 +1,148 @@
|
||||
<script>
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, onUnmounted, onMounted } from 'vue';
|
||||
import { Button, InputDialog, TopBar, MainLayout, ButtonGroup } from '@cloudron/pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
components: {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
InputDialog,
|
||||
MainLayout,
|
||||
TopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
logsModel: null,
|
||||
appsModel: null,
|
||||
busyRestart: false,
|
||||
showRestart: false,
|
||||
showFilemanager: false,
|
||||
showTerminal: false,
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
downloadUrl: '',
|
||||
logLines: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClear() {
|
||||
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
},
|
||||
onDownload() {
|
||||
this.logsModel.download();
|
||||
},
|
||||
async onRestartApp() {
|
||||
if (this.type !== 'app') return;
|
||||
const linesContainer = useTemplateRef('linesContainer');
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
const confirmed = await this.$refs.inputDialog.confirm({
|
||||
message: this.$t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: this.$t('main.dialog.yes'),
|
||||
rejectLabel: this.$t('main.dialog.no'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
let logsModel = null;
|
||||
const appsModel = AppsModel.create();
|
||||
let refreshInterval = 0;
|
||||
|
||||
if (!confirmed) return;
|
||||
const busyRestart = ref(false);
|
||||
const showRestart = ref(false);
|
||||
const showFilemanager = ref(false);
|
||||
const showTerminal = ref(false);
|
||||
const id = ref('');
|
||||
const name = ref('');
|
||||
const type = ref('');
|
||||
const downloadUrl = ref('');
|
||||
|
||||
this.busyRestart = true;
|
||||
function onClear() {
|
||||
while (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
}
|
||||
|
||||
const [error] = await this.appsModel.restart(this.id);
|
||||
if (error) return console.error(error);
|
||||
async function onRestartApp() {
|
||||
if (type.value !== 'app') return;
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const id = urlParams.get('id');
|
||||
if (!confirmed) return;
|
||||
|
||||
if (appId) {
|
||||
this.type = 'app';
|
||||
this.id = appId;
|
||||
this.name = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
this.type = 'task';
|
||||
this.id = taskId;
|
||||
this.name = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
this.type = 'crash';
|
||||
this.id = crashId;
|
||||
this.name = 'Crash ' + crashId;
|
||||
} else if (id) {
|
||||
if (id === 'box') {
|
||||
this.type = 'platform';
|
||||
this.id = id;
|
||||
this.name = 'Box';
|
||||
} else {
|
||||
this.type = 'service';
|
||||
this.id = id;
|
||||
this.name = 'Service ' + id;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
busyRestart.value = true;
|
||||
|
||||
this.logsModel = LogsModel.create(this.type, this.id);
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appsModel = AppsModel.create();
|
||||
busyRestart.value = false;
|
||||
}
|
||||
|
||||
const [error, app] = await this.appsModel.get(this.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
this.showFilemanager = !!app.manifest.addons.localstorage;
|
||||
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${this.name}`;
|
||||
|
||||
this.downloadUrl = this.logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
this.$refs.linesContainer.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId && taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
type.value = 'crash';
|
||||
id.value = crashId;
|
||||
name.value = 'Crash ' + crashId;
|
||||
} else if (idParam) {
|
||||
if (idParam === 'box') {
|
||||
type.value = 'platform';
|
||||
id.value = idParam;
|
||||
name.value = 'Box';
|
||||
} else {
|
||||
type.value = 'service';
|
||||
id.value = idParam;
|
||||
name.value = 'Service ' + idParam;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value, { appId });
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
name.value = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
showFilemanager.value = !!app.manifest.addons.localstorage;
|
||||
showTerminal.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
showRestart.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${name.value}`;
|
||||
|
||||
downloadUrl.value = logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
refreshInterval = setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else if (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
linesContainer.value.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button } from '@cloudron/pankow';
|
||||
import { Button, ClipboardAction } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import MailModel from '../models/MailModel.js';
|
||||
|
||||
@@ -84,9 +84,10 @@ onMounted(async () => {
|
||||
<div v-html="$t('email.dnsStatus.description', { emailDnsDocsLink:'https://docs.cloudron.io/email/#dns-records'})"></div>
|
||||
<br/>
|
||||
|
||||
<!-- DNS records including PTR4/PTR6 -->
|
||||
<div v-if="domainStatus.mx">
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<div>
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item">
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa-solid" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
@@ -95,17 +96,18 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ item.label }} record</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<div v-if="key === 'mx' && domain.provider === 'namecheap'">{{ $t('email.dnsStatus.namecheapInfo') }} <sup><a href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="key === 'ptr4' || key === 'ptr6'">{{ $t('email.dnsStatus.ptrInfo') }} <sup><a href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
|
||||
<div v-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
<table class="domain-status">
|
||||
<div v-else style="overflow: hidden;">
|
||||
<table class="domain-status" style="width: 100%; table-layout: fixed;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td style="width: 160px">{{ $t('email.dnsStatus.hostname') }}:</td>
|
||||
<td>{{ domainStatus[key].name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -117,12 +119,17 @@ onMounted(async () => {
|
||||
<td>{{ domainStatus[key].type }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td>{{ domainStatus[key].expected }}</td>
|
||||
<td class="domain-status-expected-label">{{ $t('email.dnsStatus.expected') }}:</td>
|
||||
<td class="domain-status-expected-value">
|
||||
<div class="domain-status-expected">{{ domainStatus[key].expected }}</div>
|
||||
<ClipboardAction :value="domainStatus[key].expected"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ $t('email.dnsStatus.current') }}:</td>
|
||||
<td>{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</td>
|
||||
<td>
|
||||
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -131,8 +138,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="domainStatus.relay" class="record-item" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<div>
|
||||
<!-- outbound SMTP / Relay status -->
|
||||
<div v-if="domainStatus.relay" class="record-item">
|
||||
<div class="record-header" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus.relay.status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus.relay.status === 'failed',
|
||||
@@ -141,33 +149,41 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ $t('email.smtpStatus.outboundSmtp') }}</b>
|
||||
<i class="fa-solid" :class="domainStatus.relay.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen">
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen" @click.stop>
|
||||
{{ domainStatus.relay.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<!-- Blacklist -->
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item">
|
||||
<div v-if="domainStatus[key]">
|
||||
<div>
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
'fa-circle-minus text-success': domainStatus[key].status === 'skipped',
|
||||
'fa-circle-minus text-warning': domainStatus[key].status === 'skipped',
|
||||
}"></i>
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ key === 'rbl4' ? 'IPv4' : 'IPv6' }} {{ $t('email.smtpStatus.rblCheck') }}</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="item.isOpen">
|
||||
<div v-if="domainStatus[key].status !== 'failed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<div v-if="domainStatus[key].status === 'passed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div v-else-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
{{ domainStatus[key] }}
|
||||
<div v-if="domainStatus[key].servers.length" v-html="$t('email.smtpStatus.blacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
<div v-else v-html="$t('email.smtpStatus.notBlacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
|
||||
<!-- servers is only the blocked servers -->
|
||||
<br/>
|
||||
<div v-for="server in domainStatus[key].servers" :key="server.name">
|
||||
<a :href="server.removal" target="_blank">{{ server.name }}</a>
|
||||
<a :href="server.removal" target="_blank">{{ server.name }} removal link</a>
|
||||
|
||||
<span v-if="server.txtRecords.length">TXT record: {{ server.txtRecords.join('. ') }}</span>
|
||||
<span v-else>No TXT Records</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,20 +194,19 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.record-item {
|
||||
.record-header {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
color: var(--pankow-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
.record-header:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.record-details {
|
||||
padding: 10px 30px;
|
||||
padding: 10px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -209,7 +224,7 @@ onMounted(async () => {
|
||||
overflow: scroll;
|
||||
white-space: nowrap;
|
||||
text-overflow: auto;
|
||||
font-weight: bold;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.domain-status > tbody > tr > td:first-of-type {
|
||||
@@ -217,4 +232,19 @@ onMounted(async () => {
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.domain-status-expected-label {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.domain-status-expected-value {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.domain-status-expected {
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -7,7 +7,10 @@ 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();
|
||||
|
||||
@@ -20,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);
|
||||
@@ -51,7 +54,7 @@ function usesPasswordAuth(provider) {
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -94,6 +97,8 @@ async function onShowDialog() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -130,6 +135,8 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
currentProvider.value = provider.value;
|
||||
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
@@ -140,6 +147,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
provider.value = result.relay.provider;
|
||||
currentProvider.value = result.relay.provider;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -167,7 +175,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
|
||||
<input type="submit" style="display: none" :disabled="busy || !isFormValid"/>
|
||||
<input type="submit" style="display: none" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
|
||||
@@ -207,7 +215,7 @@ onMounted(async () => {
|
||||
<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>
|
||||
<b>{{ prettyRelayProviderName(provider) }}</b> - <span v-html="$t('email.outbound.description')"></span>
|
||||
<span>{{ prettyRelayProviderName(currentProvider) }}</span> / <span v-html="$t('email.outbound.description')"></span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; align-items: center;">
|
||||
|
||||
@@ -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('');
|
||||
@@ -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,7 +88,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -91,25 +101,29 @@ defineExpose({
|
||||
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 ? 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;
|
||||
usersAndGroupsAndApps.value = [{ separator: true, label: 'Users' }]
|
||||
.concat(props.users)
|
||||
.concat([{ separator: true, label: 'Groups' }])
|
||||
.concat(props.groups)
|
||||
.concat([{ separator: true, label: 'Apps' }])
|
||||
.concat(props.apps);
|
||||
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroupsAndApps.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 {
|
||||
@@ -120,6 +134,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -127,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>
|
||||
@@ -154,7 +169,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name"/>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="mailbox" v-model="active" :label="$t('email.updateMailboxDialog.activeCheckbox')"/>
|
||||
@@ -179,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;
|
||||
@@ -63,7 +68,7 @@ defineExpose({
|
||||
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;
|
||||
@@ -83,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 !== ''"
|
||||
@@ -99,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>
|
||||
|
||||
+39
-14
@@ -1,18 +1,19 @@
|
||||
<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);
|
||||
@@ -26,6 +27,7 @@ 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');
|
||||
@@ -34,10 +36,12 @@ async function onSubmit() {
|
||||
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 +51,79 @@ 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;
|
||||
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.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>
|
||||
@@ -14,7 +14,7 @@ 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"/>
|
||||
@@ -24,3 +24,10 @@ const udpPorts = defineModel('udp');
|
||||
</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>
|
||||
@@ -94,11 +100,19 @@ defineProps({
|
||||
}
|
||||
}
|
||||
|
||||
.public-page-layout-left img {
|
||||
margin-bottom: 20%;
|
||||
.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;
|
||||
@@ -141,11 +155,18 @@ defineProps({
|
||||
justify-content: start;
|
||||
flex-basis: unset;
|
||||
text-align: center;
|
||||
gap: 20px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.public-page-layout-right-slot {
|
||||
max-width: unset;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.cloudron-avatar {
|
||||
border-radius: 10px;
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,6 +20,19 @@ 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>
|
||||
@@ -28,8 +45,9 @@ function onTitleBadge() {
|
||||
</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>
|
||||
@@ -61,7 +79,6 @@ function onTitleBadge() {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header-title-text {
|
||||
@@ -102,4 +119,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,6 +37,15 @@ 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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -59,7 +59,7 @@ defineExpose({
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.accessControl.sftp.port') }}</div>
|
||||
<div class="info-value">222 <ClipboardAction plain :value="222" /></div>
|
||||
<div class="info-value">222 <ClipboardAction plain value="222" /></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { onSwipe } from '@cloudron/pankow/gestures.js';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
defineProps({
|
||||
cloudronAvatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
},
|
||||
items: {
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
const sideBar = useTemplateRef('sideBar');
|
||||
const isVisible = ref(false);
|
||||
const isCollapsed = ref(!!window.localStorage['sideBarCollapsed']);
|
||||
|
||||
function open() {
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isVisible.value = false;
|
||||
}
|
||||
|
||||
function onToggleCollapse() {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
if (isCollapsed.value) window.localStorage['sideBarCollapsed'] = 'true';
|
||||
else window.localStorage.removeItem('sideBarCollapsed');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onSwipe(sideBar.value, (direction) => {
|
||||
if (direction === 'left') close();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar" ref="sideBar" :class="{ 'sidebar-closed': !isVisible, 'sidebar-collapsed': isCollapsed }">
|
||||
<Transition name="pankow-scale">
|
||||
<div class="sidebar-close-action" v-if="isVisible" @click="close()"><i class="fa-solid fa-xmark"></i></div>
|
||||
<div class="sidebar-open-action" v-else @click="open()"><i class="fa-solid fa-bars"></i></div>
|
||||
</Transition>
|
||||
<div class="sidebar-inner">
|
||||
<a href="#/" class="sidebar-logo" @click="close()">
|
||||
<img :src="cloudronAvatarUrl" :alt="cloudronName + ' icon'" v-tooltip.right="isCollapsed && !isMobile ? cloudronName : null"/> {{ cloudronName }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<SideBarItem v-for="item in items" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
:collapsed="isCollapsed"
|
||||
@close="close"
|
||||
/>
|
||||
</div>
|
||||
<div style="flex-grow: 1"></div>
|
||||
<div class="sidebar-collapse-action pankow-no-mobile" @click="onToggleCollapse()" v-tooltip.right="isCollapsed && !isMobile ? $t('main.sidebar.collapseAction') : null"><i class="fa-solid" :class="{ 'fa-arrow-left': !isCollapsed, 'fa-arrow-right': isCollapsed }"></i> <span v-if="!isCollapsed">{{ $t('main.sidebar.collapseAction') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
min-width: unset !important;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action {
|
||||
display: block;
|
||||
color: gray;
|
||||
border-radius: 3px;
|
||||
padding: 5px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 24px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-color-dark);
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 32px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 5px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
transition: left 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
position: fixed;
|
||||
left: -600px; /* depends on media query */
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, watch, inject } from 'vue';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
},
|
||||
route: {
|
||||
type: String,
|
||||
},
|
||||
active: {
|
||||
type: [ Boolean, Function ],
|
||||
default: false,
|
||||
},
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
visible: {
|
||||
type: [ Boolean, Function ],
|
||||
default: true,
|
||||
},
|
||||
childItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
});
|
||||
|
||||
const isMenuExpanded = ref(false);
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
watch(() => props.collapsed, () => {
|
||||
isMenuExpanded.value = false;
|
||||
isMenuOpen.value = false;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const active = props.active;
|
||||
return typeof active === 'function' ? active() : active ?? true;
|
||||
});
|
||||
|
||||
const isVisible = computed(() => {
|
||||
const visible = props.visible;
|
||||
return typeof visible === 'function' ? visible() : visible ?? true;
|
||||
});
|
||||
|
||||
function close() {
|
||||
isMenuOpen.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const subMenuElement = useTemplateRef('subMenuElement');
|
||||
const elem = useTemplateRef('elem');
|
||||
|
||||
function getViewport() {
|
||||
const win = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
w = win.innerWidth || e.clientWidth || g.clientWidth,
|
||||
h = win.innerHeight || e.clientHeight || g.clientHeight;
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: h
|
||||
};
|
||||
}
|
||||
|
||||
function getHiddenElementSize(element) {
|
||||
if (element) {
|
||||
const originalVisibility = element.style.visibility;
|
||||
const originalDisplay = element.style.display;
|
||||
|
||||
element.style.visibility = 'hidden';
|
||||
element.style.display = 'block';
|
||||
|
||||
element.clientHeight; // force reflow
|
||||
|
||||
const height = element.offsetHeight;
|
||||
const width = element.offsetWidth;
|
||||
|
||||
element.style.display = originalDisplay;
|
||||
element.style.visibility = originalVisibility;
|
||||
|
||||
return { height, width };
|
||||
}
|
||||
return { height: 0, width: 0 };
|
||||
}
|
||||
|
||||
const subMenuFlipped = ref(false);
|
||||
function toggleMenu() {
|
||||
if (props.collapsed && !isMobile.value) {
|
||||
const size = getHiddenElementSize(subMenuElement.value);
|
||||
const viewport = getViewport();
|
||||
|
||||
// rect of triggering element
|
||||
const top = elem.value.getBoundingClientRect().top;
|
||||
const bottom = elem.value.getBoundingClientRect().bottom;
|
||||
const right = elem.value.getBoundingClientRect().right;
|
||||
|
||||
// vertically flip or not
|
||||
subMenuFlipped.value = false;
|
||||
let menuTop = top;
|
||||
if (top + size.height - document.body.scrollTop > viewport.height) {
|
||||
if (top - document.body.scrollTop > viewport.height/2) {
|
||||
menuTop = bottom - size.height;
|
||||
subMenuFlipped.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
subMenuElement.value.style.left = right + 10 + 'px';
|
||||
subMenuElement.value.style.top = menuTop + 'px';
|
||||
|
||||
isMenuOpen.value = true;
|
||||
} else {
|
||||
isMenuExpanded.value = !isMenuExpanded.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdrop(event) {
|
||||
isMenuOpen.value = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible">
|
||||
<hr v-if="separator"/>
|
||||
<a v-else-if="!childItems?.length" class="sidebar-item" :class="{ active: isActive }" :href="route" @click="close()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }}</span></a>
|
||||
<div v-else-if="childItems.length" ref="elem" class="sidebar-item" :class="{ 'sidebar-item-menu-open': isMenuOpen ? '#e9ecef' : null }" @click="toggleMenu()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: isMenuExpanded }" style="margin-left: 6px;"></i></span></div>
|
||||
|
||||
<teleport to="#app">
|
||||
<div class="pankow-menu-backdrop" @click="onBackdrop($event)" @contextmenu="onBackdrop($event)" v-show="isMenuOpen"></div>
|
||||
<div v-show="isMenuOpen" ref="subMenuElement" class="sidebar-item-menu">
|
||||
<div :class="{ 'sidebar-item-menu-anchor': !subMenuFlipped }">
|
||||
<span class="sidebar-item-header">{{ label }}</span>
|
||||
</div>
|
||||
<div v-for="(item, index) in childItems" :key="item" :class="{ 'sidebar-item-menu-anchor': subMenuFlipped && index === childItems.length-1 }">
|
||||
<hr v-if="item.separator"/>
|
||||
<a v-else class="sidebar-item" :href="item.route" @click="close()"><i :class="item.icon"></i> {{ item.label }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="isMenuExpanded">
|
||||
<SideBarItem v-for="item in childItems" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
@close="close()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar-item-menu {
|
||||
position: absolute;
|
||||
z-index: 3002; /* backdrop is at 3001 -> see pankow */
|
||||
background-color: var(--navbar-background);
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
border-bottom-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item-menu-open {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-menu-anchor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 37px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: var(--pankow-font-weight-bold);
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item-header,
|
||||
.sidebar-item:hover,
|
||||
.sidebar-item-menu-open,
|
||||
.sidebar-item-menu-anchor::before {
|
||||
background-color: #282d38;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-label-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar-item-label-collapsed {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -7,14 +7,14 @@ defineProps({
|
||||
},
|
||||
state: {
|
||||
validator(value) {
|
||||
// The value must match one of these strings
|
||||
return ['success', 'warning', 'danger', ''].includes(value);
|
||||
return ['success', 'warning', 'danger', 'idle', ''].includes(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function color(state) {
|
||||
if (state === 'success') return '#27CE65';
|
||||
else if (state === 'idle') return '#BCD0C3';
|
||||
else if (state === 'warning') return '#f0ad4e';
|
||||
else if (state === 'danger') return '#d9534f';
|
||||
else return '#7c7c7c';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user