Merge branch 'dev' into feature-schedule-wf-trigger

This commit is contained in:
shamoon
2024-11-22 13:33:23 -08:00
65 changed files with 1611 additions and 346 deletions

View File

@@ -253,6 +253,10 @@
<context context-type="sourcefile">src/app/app.component.ts</context>
<context context-type="linenumber">87</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">118</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html</context>
<context context-type="linenumber">37</context>
@@ -520,6 +524,10 @@
<context context-type="sourcefile">src/app/components/admin/config/config.component.html</context>
<context context-type="linenumber">34</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">124</context>
</context-group>
</trans-unit>
<trans-unit id="3823219296477075982" datatype="html">
<source>Discard</source>
@@ -576,7 +584,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">43</context>
<context context-type="linenumber">57</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -584,7 +592,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">99</context>
<context context-type="linenumber">184</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
@@ -712,6 +720,14 @@
<context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context>
<context context-type="linenumber">23</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">111</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">127</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/system-status-dialog/system-status-dialog.component.html</context>
<context context-type="linenumber">10</context>
@@ -1095,7 +1111,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">37</context>
<context context-type="linenumber">51</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/input/permissions/permissions-form/permissions-form.component.html</context>
@@ -1468,11 +1484,11 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">57</context>
<context context-type="linenumber">59</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">86</context>
<context context-type="linenumber">88</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.html</context>
@@ -1707,7 +1723,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">42</context>
<context context-type="linenumber">56</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
@@ -1719,7 +1735,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">98</context>
<context context-type="linenumber">183</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context>
@@ -2208,11 +2224,11 @@
<source>Confirm delete</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">55</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">80</context>
<context context-type="linenumber">82</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
@@ -2227,18 +2243,18 @@
<source>This operation will permanently delete this document.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">54</context>
<context context-type="linenumber">56</context>
</context-group>
</trans-unit>
<trans-unit id="5641451190833696892" datatype="html">
<source>This operation cannot be undone.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">55</context>
<context context-type="linenumber">57</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">84</context>
<context context-type="linenumber">86</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context>
@@ -2273,14 +2289,14 @@
<source>Document deleted</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">64</context>
<context context-type="linenumber">66</context>
</context-group>
</trans-unit>
<trans-unit id="7295637485862454066" datatype="html">
<source>Error deleting document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">69</context>
<context context-type="linenumber">71</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
@@ -2291,56 +2307,56 @@
<source>This operation will permanently delete the selected documents.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">82</context>
<context context-type="linenumber">84</context>
</context-group>
</trans-unit>
<trans-unit id="6804051092296228130" datatype="html">
<source>This operation will permanently delete all documents in the trash.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">83</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="6996183233986182894" datatype="html">
<source>Document(s) deleted</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">94</context>
<context context-type="linenumber">96</context>
</context-group>
</trans-unit>
<trans-unit id="6962724852893361467" datatype="html">
<source>Error deleting document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">101</context>
<context context-type="linenumber">103</context>
</context-group>
</trans-unit>
<trans-unit id="7534569062269274401" datatype="html">
<source>Document restored</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">113</context>
<context context-type="linenumber">116</context>
</context-group>
</trans-unit>
<trans-unit id="9136016619414048201" datatype="html">
<source>Error restoring document</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">117</context>
<context context-type="linenumber">126</context>
</context-group>
</trans-unit>
<trans-unit id="960063472770266304" datatype="html">
<source>Document(s) restored</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">127</context>
<context context-type="linenumber">136</context>
</context-group>
</trans-unit>
<trans-unit id="8405416976953346141" datatype="html">
<source>Error restoring document(s)</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/admin/trash/trash.component.ts</context>
<context context-type="linenumber">133</context>
<context context-type="linenumber">142</context>
</context-group>
</trans-unit>
<trans-unit id="8119815638230251386" datatype="html">
@@ -2518,7 +2534,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">159</context>
<context context-type="linenumber">173</context>
</context-group>
</trans-unit>
<trans-unit id="2753185112875184719" datatype="html">
@@ -2921,21 +2937,21 @@
<source>Sidebar views updated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">208</context>
<context context-type="linenumber">209</context>
</context-group>
</trans-unit>
<trans-unit id="3547923076537026828" datatype="html">
<source>Error updating sidebar views</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">211</context>
<context context-type="linenumber">212</context>
</context-group>
</trans-unit>
<trans-unit id="2526035785704676448" datatype="html">
<source>An error occurred while saving update checking settings.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context>
<context context-type="linenumber">232</context>
<context context-type="linenumber">233</context>
</context-group>
</trans-unit>
<trans-unit id="4580988005648117665" datatype="html">
@@ -3728,7 +3744,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">18</context>
<context context-type="linenumber">20</context>
</context-group>
</trans-unit>
<trans-unit id="4249303448466017578" datatype="html">
@@ -4271,7 +4287,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">8</context>
<context context-type="linenumber">10</context>
</context-group>
</trans-unit>
<trans-unit id="5342432350421167093" datatype="html">
@@ -4282,7 +4298,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">28</context>
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="3586674587150281199" datatype="html">
@@ -4293,7 +4309,7 @@
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">29</context>
<context context-type="linenumber">31</context>
</context-group>
</trans-unit>
<trans-unit id="8204176479746810612" datatype="html">
@@ -4331,18 +4347,70 @@
<context context-type="linenumber">30</context>
</context-group>
</trans-unit>
<trans-unit id="8900662509426586619" datatype="html">
<source>Two-factor Authentication</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">37</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">104</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">138</context>
</context-group>
</trans-unit>
<trans-unit id="8418597938335066730" datatype="html">
<source>Disable Two-factor Authentication</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">39</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context>
<context context-type="linenumber">41</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">169</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">171</context>
</context-group>
</trans-unit>
<trans-unit id="1436831433675346331" datatype="html">
<source>Create new user account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">44</context>
<context context-type="linenumber">49</context>
</context-group>
</trans-unit>
<trans-unit id="2887331217965896363" datatype="html">
<source>Edit user account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">48</context>
<context context-type="linenumber">53</context>
</context-group>
</trans-unit>
<trans-unit id="5872286584705575476" datatype="html">
<source>Totp deactivated</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">109</context>
</context-group>
</trans-unit>
<trans-unit id="6439190193788239059" datatype="html">
<source>Totp deactivation failed</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">112</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.ts</context>
<context context-type="linenumber">117</context>
</context-group>
</trans-unit>
<trans-unit id="8419515490539218007" datatype="html">
@@ -5254,32 +5322,36 @@
<source>Confirm Email</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">13</context>
<context context-type="linenumber">15</context>
</context-group>
</trans-unit>
<trans-unit id="3241357959735682038" datatype="html">
<source>Confirm Password</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">23</context>
<context context-type="linenumber">25</context>
</context-group>
</trans-unit>
<trans-unit id="7554924397178347823" datatype="html">
<source>API Auth Token</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">31</context>
<context context-type="linenumber">33</context>
</context-group>
</trans-unit>
<trans-unit id="4323470180912194028" datatype="html">
<source>Copy</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">35</context>
<context context-type="linenumber">37</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">42</context>
<context context-type="linenumber">44</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">156</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
@@ -5310,14 +5382,18 @@
<source>Regenerate auth token</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">45</context>
<context context-type="linenumber">47</context>
</context-group>
</trans-unit>
<trans-unit id="5392341774767336507" datatype="html">
<source>Copied!</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">53</context>
<context context-type="linenumber">55</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">163</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context>
@@ -5328,91 +5404,176 @@
<source>Warning: changing the token cannot be undone</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">55</context>
<context context-type="linenumber">57</context>
</context-group>
</trans-unit>
<trans-unit id="8935717557476105185" datatype="html">
<source>Connected social accounts</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">59</context>
<context context-type="linenumber">63</context>
</context-group>
</trans-unit>
<trans-unit id="8383227756109993898" datatype="html">
<source>Set a password before disconnecting social account.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">63</context>
<context context-type="linenumber">67</context>
</context-group>
</trans-unit>
<trans-unit id="2907016025519254862" datatype="html">
<source>Disconnect</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">69</context>
<context context-type="linenumber">73</context>
</context-group>
</trans-unit>
<trans-unit id="5322995394400578831" datatype="html">
<source>Disconnect <x id="INTERPOLATION" equiv-text="{{ account.name }}"/> social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">71</context>
<context context-type="linenumber">75</context>
</context-group>
</trans-unit>
<trans-unit id="649824314893051979" datatype="html">
<source>Warning: disconnecting social accounts cannot be undone</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">81</context>
<context context-type="linenumber">85</context>
</context-group>
</trans-unit>
<trans-unit id="1375396510511350122" datatype="html">
<source>Connect new social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">86</context>
<context context-type="linenumber">90</context>
</context-group>
</trans-unit>
<trans-unit id="4187671210825254690" datatype="html">
<source>Scan the QR code with your authenticator app and then enter the code below</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">115</context>
</context-group>
</trans-unit>
<trans-unit id="5867169599865838267" datatype="html">
<source>Authenticator secret</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">118</context>
</context-group>
</trans-unit>
<trans-unit id="5331198279926709145" datatype="html">
<source>You can store this secret and use it to reinstall your authenticator app at a later time.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">119</context>
</context-group>
</trans-unit>
<trans-unit id="8186013988289067040" datatype="html">
<source>Code</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">122</context>
</context-group>
</trans-unit>
<trans-unit id="3176701652604668614" datatype="html">
<source>Recovery codes will not be shown again, make sure to save them.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">141</context>
</context-group>
</trans-unit>
<trans-unit id="2722512118372958038" datatype="html">
<source>Copy codes</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.html</context>
<context context-type="linenumber">159</context>
</context-group>
</trans-unit>
<trans-unit id="6141884091799403188" datatype="html">
<source>Emails must match</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">108</context>
<context context-type="linenumber">121</context>
</context-group>
</trans-unit>
<trans-unit id="5281933990298241826" datatype="html">
<source>Passwords must match</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">136</context>
<context context-type="linenumber">149</context>
</context-group>
</trans-unit>
<trans-unit id="4219429959475101385" datatype="html">
<source>Profile updated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">156</context>
<context context-type="linenumber">170</context>
</context-group>
</trans-unit>
<trans-unit id="3417726855410304962" datatype="html">
<source>Error saving profile</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">168</context>
<context context-type="linenumber">182</context>
</context-group>
</trans-unit>
<trans-unit id="154249228726292516" datatype="html">
<source>Error generating auth token</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">185</context>
<context context-type="linenumber">199</context>
</context-group>
</trans-unit>
<trans-unit id="4153637646944982460" datatype="html">
<source>Error disconnecting social account</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">210</context>
<context context-type="linenumber">224</context>
</context-group>
</trans-unit>
<trans-unit id="5939111172212776886" datatype="html">
<source>Error fetching TOTP settings</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">243</context>
</context-group>
</trans-unit>
<trans-unit id="1030314492414713260" datatype="html">
<source>TOTP activated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">264</context>
</context-group>
</trans-unit>
<trans-unit id="3755006064892435830" datatype="html">
<source>Error activating TOTP</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">266</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">272</context>
</context-group>
</trans-unit>
<trans-unit id="5919827473541889422" datatype="html">
<source>TOTP deactivated successfully</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">288</context>
</context-group>
</trans-unit>
<trans-unit id="6214722303383624015" datatype="html">
<source>Error deactivating TOTP</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">290</context>
</context-group>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context>
<context context-type="linenumber">295</context>
</context-group>
</trans-unit>
<trans-unit id="3797570084942068182" datatype="html">
@@ -7351,18 +7512,32 @@
<context context-type="linenumber">264</context>
</context-group>
</trans-unit>
<trans-unit id="3629960544875360046" datatype="html">
<source>Previous page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">280</context>
</context-group>
</trans-unit>
<trans-unit id="3337301694210287595" datatype="html">
<source>Next page</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">292</context>
</context-group>
</trans-unit>
<trans-unit id="2155249406916744630" datatype="html">
<source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">300</context>
<context context-type="linenumber">324</context>
</context-group>
</trans-unit>
<trans-unit id="6837554170707123455" datatype="html">
<source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
<context context-type="linenumber">343</context>
<context context-type="linenumber">367</context>
</context-group>
</trans-unit>
<trans-unit id="739880801667335279" datatype="html">

View File

@@ -145,7 +145,6 @@ import {
asterisk,
braces,
bodyText,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -186,6 +185,7 @@ import {
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkRichtext,
files,
fileText,
filter,
@@ -253,7 +253,6 @@ const icons = {
asterisk,
braces,
bodyText,
boxArrowInRight,
boxArrowUp,
boxArrowUpRight,
boxes,
@@ -294,6 +293,7 @@ const icons = {
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
fileEarmarkRichtext,
files,
fileText,
filter,

View File

@@ -16,6 +16,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { By } from '@angular/platform-browser'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ToastService } from 'src/app/services/toast.service'
import { Router } from '@angular/router'
const documentsInTrash = [
{
@@ -38,6 +39,7 @@ describe('TrashComponent', () => {
let trashService: TrashService
let modalService: NgbModal
let toastService: ToastService
let router: Router
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -61,6 +63,7 @@ describe('TrashComponent', () => {
trashService = TestBed.inject(TrashService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
component = fixture.componentInstance
fixture.detectChanges()
})
@@ -161,6 +164,22 @@ describe('TrashComponent', () => {
expect(restoreSpy).toHaveBeenCalledWith([1, 2])
})
it('should offer link to restored document', () => {
let toasts
const navigateSpy = jest.spyOn(router, 'navigate')
toastService.getToasts().subscribe((allToasts) => {
toasts = [...allToasts]
})
jest.spyOn(trashService, 'restoreDocuments').mockReturnValue(of('OK'))
component.restore(documentsInTrash[0])
expect(toasts.length).toEqual(1)
toasts[0].action()
expect(navigateSpy).toHaveBeenCalledWith([
'documents',
documentsInTrash[0].id,
])
})
it('should support toggle all items in view', () => {
component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0)

View File

@@ -7,6 +7,7 @@ import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dial
import { Subject, takeUntil } from 'rxjs'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Router } from '@angular/router'
@Component({
selector: 'pngx-trash',
@@ -26,7 +27,8 @@ export class TrashComponent implements OnDestroy {
private trashService: TrashService,
private toastService: ToastService,
private modalService: NgbModal,
private settingsService: SettingsService
private settingsService: SettingsService,
private router: Router
) {
this.reload()
}
@@ -110,7 +112,14 @@ export class TrashComponent implements OnDestroy {
restore(document: Document) {
this.trashService.restoreDocuments([document.id]).subscribe({
next: () => {
this.toastService.showInfo($localize`Document restored`)
this.toastService.show({
content: $localize`Document restored`,
delay: 5000,
actionName: $localize`Open document`,
action: () => {
this.router.navigate(['documents', document.id])
},
})
this.reload()
},
error: (err) => {

View File

@@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
component.editProfile()
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
})

View File

@@ -136,6 +136,7 @@ export class AppFrameComponent
editProfile() {
this.modalService.open(ProfileEditDialogComponent, {
backdrop: 'static',
size: 'xl',
})
this.closeMenu()
}

View File

@@ -49,7 +49,7 @@
[disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye"></i-bs>
@@ -72,7 +72,7 @@
<i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="box-arrow-in-right"></i-bs>
<i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
}
</button>

View File

@@ -32,6 +32,20 @@
</div>
<pngx-input-select i18n-title title="Groups" [items]="groups" multiple="true" formControlName="groups"></pngx-input-select>
@if (object?.is_mfa_enabled && currentUserIsSuperUser) {
<label class="form-label" i18n>Two-factor Authentication</label>
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
<div class="col">
<pngx-permissions-select i18n-title title="Permissions" formControlName="user_permissions" [error]="error?.user_permissions" [inheritedPermissions]="inheritedPermissions"></pngx-permissions-select>

View File

@@ -7,7 +7,7 @@ import {
} from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs'
import { of, throwError } from 'rxjs'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { GroupService } from 'src/app/services/rest/group.service'
@@ -21,10 +21,15 @@ import { EditDialogMode } from '../edit-dialog.component'
import { UserEditDialogComponent } from './user-edit-dialog.component'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { ToastService } from 'src/app/services/toast.service'
import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsService } from 'src/app/services/permissions.service'
describe('UserEditDialogComponent', () => {
let component: UserEditDialogComponent
let settingsService: SettingsService
let permissionsService: PermissionsService
let toastService: ToastService
let fixture: ComponentFixture<UserEditDialogComponent>
beforeEach(async () => {
@@ -71,6 +76,8 @@ describe('UserEditDialogComponent', () => {
fixture = TestBed.createComponent(UserEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 99, username: 'user99' }
permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService)
component = fixture.componentInstance
fixture.detectChanges()
@@ -121,4 +128,38 @@ describe('UserEditDialogComponent', () => {
component.save()
expect(component.passwordIsSet).toBeTruthy()
})
it('should support deactivation of TOTP', () => {
component.object = { id: 99, username: 'user99' }
const deactivateSpy = jest.spyOn(
component['service'] as UserService,
'deactivateTotp'
)
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should check superuser status of current user', () => {
expect(component.currentUserIsSuperUser).toBeFalsy()
permissionsService.initialize([], {
id: 99,
username: 'user99',
is_superuser: true,
})
expect(component.currentUserIsSuperUser).toBeTruthy()
})
})

View File

@@ -5,9 +5,11 @@ import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Group } from 'src/app/data/group'
import { User } from 'src/app/data/user'
import { PermissionsService } from 'src/app/services/permissions.service'
import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-user-edit-dialog',
@@ -20,12 +22,15 @@ export class UserEditDialogComponent
{
groups: Group[]
passwordIsSet: boolean = false
public totpLoading: boolean = false
constructor(
service: UserService,
activeModal: NgbActiveModal,
groupsService: GroupService,
settingsService: SettingsService
settingsService: SettingsService,
private toastService: ToastService,
private permissionsService: PermissionsService
) {
super(service, activeModal, service, settingsService)
@@ -87,4 +92,30 @@ export class UserEditDialogComponent
.length > 0
super.save()
}
get currentUserIsSuperUser(): boolean {
return this.permissionsService.isSuperUser()
}
deactivateTotp() {
this.totpLoading = true
;(this.service as UserService)
.deactivateTotp(this.object)
.pipe(first())
.subscribe({
next: (result) => {
this.totpLoading = false
if (result) {
this.toastService.showInfo($localize`Totp deactivated`)
this.object.is_mfa_enabled = false
} else {
this.toastService.showError($localize`Totp deactivation failed`)
}
},
error: (e) => {
this.totpLoading = false
this.toastService.showError($localize`Totp deactivation failed`, e)
},
})
}
}

View File

@@ -35,7 +35,7 @@
</div>
@if (selectionModel.items) {
<div class="items" #buttonItems>
@for (item of selectionModel.itemsSorted | filter: filterText:'name'; track item; let i = $index) {
@for (item of selectionModel.items | filter: filterText:'name'; track item; let i = $index) {
@if (allowSelectNone || item.id) {
<pngx-toggleable-dropdown-button
[item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggled)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
@@ -45,13 +45,13 @@
</div>
}
@if (editing) {
@if ((selectionModel.itemsSorted | filter: filterText:'name').length === 0 && createRef !== undefined) {
@if ((selectionModel.items | filter: filterText:'name').length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
</button>
}
@if ((selectionModel.itemsSorted | filter: filterText:'name').length > 0) {
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>

View File

@@ -501,7 +501,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
component.selectionModel = selectionModel
selectionModel.toggle(items[1].id)
selectionModel.apply()
expect(selectionModel.itemsSorted).toEqual([
expect(selectionModel.items).toEqual([
nullItem,
{ id: null, name: 'Null B' },
items[1],

View File

@@ -43,11 +43,18 @@ export class FilterableDropdownSelectionModel {
private _intersection: Intersection = Intersection.Include
temporaryIntersection: Intersection = this._intersection
items: MatchingModel[] = []
private _items: MatchingModel[] = []
get items(): MatchingModel[] {
return this._items
}
get itemsSorted(): MatchingModel[] {
// TODO: this is getting called very often
return this.items.sort((a, b) => {
set items(items: MatchingModel[]) {
this._items = items
this.sortItems()
}
private sortItems() {
this._items.sort((a, b) => {
if (a.id == null && b.id != null) {
return -1
} else if (a.id != null && b.id == null) {
@@ -291,6 +298,7 @@ export class FilterableDropdownSelectionModel {
})
this._logicalOperator = this.temporaryLogicalOperator
this._intersection = this.temporaryIntersection
this.sortItems()
}
reset(complete: boolean = false) {

View File

@@ -5,94 +5,179 @@
</button>
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
<div class="row">
<div class="col-12 col-md-6">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
</div>
</div>
</div>
</div>
</div>
</div>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
</div>
</div>
</div>
</div>
</div>
</div>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3">
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
}
@if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
<pngx-confirm-button
title="Regenerate auth token"
i18n-title
buttonClasses=" btn-outline-secondary"
iconName="arrow-repeat"
[disabled]="!hasUsablePassword"
(confirm)="generateAuthToken()">
</pngx-confirm-button>
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
@if (socialAccounts?.length > 0) {
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts; track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<label class="form-label" i18n>API Auth Token</label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
}
@if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
}
<span class="visually-hidden" i18n>Copy</span>
</button>
<pngx-confirm-button
label="Disconnect"
i18n-label
title="Disconnect {{ account.name }} social account"
title="Regenerate auth token"
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
buttonClasses=" btn-outline-secondary"
iconName="arrow-repeat"
[disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)">
(confirm)="generateAuthToken()">
</pngx-confirm-button>
</li>
}
</ul>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div>
}
@if (socialAccountProviders?.length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<div class="list-group">
@for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
</a>
}
</div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div>
</div>
}
</div>
<div class="col-12 col-md-6">
@if (socialAccounts?.length > 0) {
<div class="mb-3">
<p i18n>Connected social accounts</p>
<ul class="list-group">
@for (account of socialAccounts; track account.id) {
<li class="list-group-item"
ngbPopover="Set a password before disconnecting social account."
i18n-ngbPopover
[disablePopover]="hasUsablePassword"
triggers="mouseenter:mouseleave">
{{account.name}} ({{account.provider}})
<pngx-confirm-button
label="Disconnect"
i18n-label
title="Disconnect {{ account.name }} social account"
i18n-title
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
iconName="trash"
[disabled]="!hasUsablePassword"
(confirm)="disconnectSocialAccount(account.id)">
</pngx-confirm-button>
</li>
}
</ul>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
</div>
}
@if (socialAccountProviders?.length > 0) {
<div class="mb-3">
<p i18n>Connect new social account</p>
<div class="list-group">
@for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
</a>
}
</div>
</div>
}
@if (!isTotpEnabled) {
<div ngbAccordion>
<div ngbAccordionItem>
<h2 ngbAccordionHeader>
<button ngbAccordionButton (click)="gettotpSettings()" i18n>Two-factor Authentication</button>
</h2>
<div ngbAccordionCollapse>
<div ngbAccordionBody>
<ng-template>
@if (totpSettingsLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
} @else if (totpSettings) {
<figure class="figure">
<div class="bg-white d-inline-block" [innerHTML]="totpSettings.qr_svg | safeHtml"></div>
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
</figure>
<p>
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
</p>
<div class="input-group mb-3">
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
<ng-container i18n>Enable</ng-container>
@if (totpLoading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
}
</button>
</div>
}
</ng-template>
</div>
</div>
</div>
</div>
} @else {
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) {
<div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle"></i-bs>&nbsp;<ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50">
@for (code of recoveryCodes; track code; let i = $index) {
@if (i % 2 === 0) {
<li class="list-group-item d-flex justify-content-around align-items-center">
<code>{{code}}</code>
@if (recoveryCodes[i + 1]) {
<code>{{recoveryCodes[i + 1]}}</code>
}
</li>
}
}
</ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
&nbsp;<span i18n>Copy codes</span>
}
@if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
&nbsp;<span class="text-primary" i18n>Copied!</span>
}
</button>
</div>
}
<pngx-confirm-button
label="Disable Two-factor Authentication"
i18n-label
title="Disable Two-factor Authentication"
i18n-title
buttonClasses="btn-outline-danger btn-sm"
iconName="trash"
[disabled]="totpLoading"
(confirm)="deactivateTotp()">
</pngx-confirm-button>
}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>

View File

@@ -294,4 +294,85 @@ describe('ProfileEditDialogComponent', () => {
expect(disconnectSpy).toHaveBeenCalled()
expect(component.socialAccounts).not.toContainEqual(socialAccount)
})
it('should get totp settings', () => {
const settings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
const getSpy = jest.spyOn(profileService, 'getTotpSettings')
const toastSpy = jest.spyOn(toastService, 'showError')
getSpy.mockReturnValueOnce(
throwError(() => new Error('failed to get settings'))
)
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
getSpy.mockReturnValue(of(settings))
component.gettotpSettings()
expect(getSpy).toHaveBeenCalled()
expect(component.totpSettings).toEqual(settings)
})
it('should activate totp', () => {
const activateSpy = jest.spyOn(profileService, 'activateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to activate totp')
activateSpy.mockReturnValueOnce(throwError(() => error))
component.totpSettings = {
url: 'http://localhost/',
qr_svg: 'svg',
secret: 'secret',
}
component.form.get('totp_code').patchValue('123456')
component.activateTotp()
expect(activateSpy).toHaveBeenCalledWith(
component.totpSettings.secret,
component.form.get('totp_code').value
)
expect(toastErrorSpy).toHaveBeenCalled()
activateSpy.mockReturnValueOnce(of({ success: false, recovery_codes: [] }))
component.activateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error activating TOTP', error)
activateSpy.mockReturnValueOnce(
of({ success: true, recovery_codes: ['1', '2', '3'] })
)
component.activateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeTruthy()
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
})
it('should deactivate totp', () => {
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const error = new Error('failed to deactivate totp')
deactivateSpy.mockReturnValueOnce(throwError(() => error))
component.deactivateTotp()
expect(deactivateSpy).toHaveBeenCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deactivateSpy.mockReturnValueOnce(of(false))
component.deactivateTotp()
expect(toastErrorSpy).toHaveBeenCalledWith('Error deactivating TOTP', error)
deactivateSpy.mockReturnValueOnce(of(true))
component.deactivateTotp()
expect(toastInfoSpy).toHaveBeenCalled()
expect(component.isTotpEnabled).toBeFalsy()
})
it('should copy recovery codes', fakeAsync(() => {
const copySpy = jest.spyOn(clipboard, 'copy')
component.recoveryCodes = ['1', '2', '3']
component.copyRecoveryCodes()
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
tick(3000)
}))
})

View File

@@ -2,7 +2,11 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
import { FormControl, FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileService } from 'src/app/services/profile.service'
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
import {
TotpSettings,
SocialAccount,
SocialAccountProvider,
} from 'src/app/data/user-profile'
import { ToastService } from 'src/app/services/toast.service'
import { Subject, takeUntil } from 'rxjs'
import { Clipboard } from '@angular/cdk/clipboard'
@@ -25,6 +29,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
first_name: new FormControl(''),
last_name: new FormControl(''),
auth_token: new FormControl(''),
totp_code: new FormControl(''),
})
private currentPassword: string
@@ -38,7 +43,14 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
private emailConfirm: string
public showEmailConfirm: boolean = false
public isTotpEnabled: boolean = false
public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false
public totpLoading: boolean = false
public recoveryCodes: string[]
public copied: boolean = false
public codesCopied: boolean = false
public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = []
@@ -70,6 +82,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
this.onPasswordChange()
})
this.socialAccounts = profile.social_accounts
this.isTotpEnabled = profile.is_mfa_enabled
})
this.profileService
@@ -147,6 +160,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value)
delete profile.totp_code
this.networkActive = true
this.profileService
.update(profile)
@@ -213,4 +227,81 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
},
})
}
public gettotpSettings(): void {
this.totpSettingsLoading = true
this.profileService
.getTotpSettings()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (totpSettings) => {
this.totpSettingsLoading = false
this.totpSettings = totpSettings
},
error: (error) => {
this.toastService.showError(
$localize`Error fetching TOTP settings`,
error
)
this.totpSettingsLoading = false
},
})
}
public activateTotp(): void {
this.totpLoading = true
this.form.get('totp_code').disable()
this.profileService
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (activationResponse) => {
this.totpLoading = false
this.isTotpEnabled = activationResponse.success
this.recoveryCodes = activationResponse.recovery_codes
this.form.get('totp_code').enable()
if (activationResponse.success) {
this.toastService.showInfo($localize`TOTP activated successfully`)
} else {
this.toastService.showError($localize`Error activating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.form.get('totp_code').enable()
this.toastService.showError($localize`Error activating TOTP`, error)
},
})
}
public deactivateTotp(): void {
this.totpLoading = true
this.profileService
.deactivateTotp()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (success) => {
this.totpLoading = false
this.isTotpEnabled = !success
this.recoveryCodes = null
if (success) {
this.toastService.showInfo($localize`TOTP deactivated successfully`)
} else {
this.toastService.showError($localize`Error deactivating TOTP`)
}
},
error: (error) => {
this.totpLoading = false
this.toastService.showError($localize`Error deactivating TOTP`, error)
},
})
}
public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied = true
setTimeout(() => {
this.codesCopied = false
}, 3000)
}
}

View File

@@ -54,7 +54,7 @@
<i-bs name="diagram-3"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="box-arrow-in-right"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Open</span>
<i-bs name="file-earmark-richtext"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Open</span>
</a>
<a class="btn btn-sm btn-outline-secondary" target="_blank" [href]="previewUrl"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"

View File

@@ -127,7 +127,7 @@
<div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100">
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" title="Open" i18n-title *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n-title>
<i-bs name="box-arrow-in-right"></i-bs>
<i-bs name="file-earmark-richtext"></i-bs>
</a>
<a [href]="previewUrl" target="_blank" class="btn btn-sm btn-outline-secondary"
[ngbPopover]="previewContent" [popoverTitle]="document.title | documentTitle"

View File

@@ -698,5 +698,31 @@ describe('DocumentListComponent', () => {
fixture.detectChanges()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'o' }))
expect(detailSpy).toHaveBeenCalledWith(docs[1].id)
const lotsOfDocs: Document[] = Array.from({ length: 100 }, (_, i) => ({
id: i + 1,
title: `Doc${i + 1}`,
notes: [],
tags$: new Subject(),
content: `document content ${i + 1}`,
}))
jest
.spyOn(documentListService, 'documents', 'get')
.mockReturnValue(lotsOfDocs)
jest
.spyOn(documentService, 'listAllFilteredIds')
.mockReturnValue(of(lotsOfDocs.map((d) => d.id)))
jest.spyOn(documentListService, 'getLastPage').mockReturnValue(4)
fixture.detectChanges()
expect(component.list.currentPage).toEqual(1)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', ctrlKey: true })
)
expect(component.list.currentPage).toEqual(2)
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', ctrlKey: true })
)
expect(component.list.currentPage).toEqual(1)
})
})

View File

@@ -273,6 +273,30 @@ export class DocumentListComponent
}
}
})
this.hotKeyService
.addShortcut({
keys: 'control.arrowleft',
description: $localize`Previous page`,
})
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.currentPage > 1) {
this.list.currentPage--
}
})
this.hotKeyService
.addShortcut({
keys: 'control.arrowright',
description: $localize`Next page`,
})
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
if (this.list.currentPage < this.list.getLastPage()) {
this.list.currentPage++
}
})
}
ngOnDestroy() {

View File

@@ -77,14 +77,19 @@ describe('CorrespondentListComponent', () => {
it('should support very old date strings', () => {
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
of({
count: 1,
all: [1],
count: 2,
all: [1, 2],
results: [
{
id: 1,
name: 'Correspondent1',
last_correspondence: '1832-12-31T15:32:54-07:52:58',
},
{
id: 2,
name: 'Correspondent2',
last_correspondence: '1901-07-01T00:00:00+00:09:21',
},
],
})
)

View File

@@ -52,7 +52,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
date = new Date(
c.last_correspondence
?.toString()
.replace(/-(\d\d):\d\d:\d\d/gm, `-$1:00`)
.replace(/([-+])(\d\d):\d\d:\d\d/gm, `$1$2:00`)
)
}
return this.datePipe.transform(date)

View File

@@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
result?: string
related_document?: number
owner?: number
}

View File

@@ -17,4 +17,11 @@ export interface PaperlessUserProfile {
auth_token?: string
social_accounts?: SocialAccount[]
has_usable_password?: boolean
is_mfa_enabled?: boolean
}
export interface TotpSettings {
url: string
qr_svg: string
secret: string
}

View File

@@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
groups?: number[] // Group[]
user_permissions?: string[]
inherited_permissions?: string[]
is_mfa_enabled?: boolean
}

View File

@@ -439,4 +439,25 @@ describe('PermissionsService', () => {
expect(permissionsService.isAdmin()).toBeFalsy()
})
it('correctly checks superuser status', () => {
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
is_superuser: true,
})
expect(permissionsService.isSuperUser()).toBeTruthy()
permissionsService.initialize([], {
username: 'testuser',
last_name: 'User',
first_name: 'Test',
id: 1,
})
expect(permissionsService.isSuperUser()).toBeFalsy()
})
})

View File

@@ -56,6 +56,10 @@ export class PermissionsService {
return this.currentUser?.is_staff
}
public isSuperUser(): boolean {
return this.currentUser?.is_superuser
}
public currentUserOwnsObject(object: ObjectWithPermissions): boolean {
return (
!object ||

View File

@@ -72,4 +72,32 @@ describe('ProfileService', () => {
)
expect(req.request.method).toEqual('GET')
})
it('calls get totp settings endpoint', () => {
service.getTotpSettings().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('GET')
})
it('calls activate totp endpoint', () => {
service.activateTotp('secret', 'code').subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({
secret: 'secret',
code: 'code',
})
})
it('calls deactivate totp endpoint', () => {
service.deactivateTotp().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}profile/totp/`
)
expect(req.request.method).toEqual('DELETE')
})
})

View File

@@ -2,6 +2,7 @@ import { HttpClient } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import {
TotpSettings,
PaperlessUserProfile,
SocialAccountProvider,
} from '../data/user-profile'
@@ -47,4 +48,30 @@ export class ProfileService {
`${environment.apiBaseUrl}${this.endpoint}/social_account_providers/`
)
}
getTotpSettings(): Observable<TotpSettings> {
return this.http.get<TotpSettings>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`
)
}
activateTotp(
totpSecret: string,
totpCode: string
): Observable<{ success: boolean; recovery_codes: string[] }> {
return this.http.post<{ success: boolean; recovery_codes: string[] }>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{
secret: totpSecret,
code: totpCode,
}
)
}
deactivateTotp(): Observable<boolean> {
return this.http.delete<boolean>(
`${environment.apiBaseUrl}${this.endpoint}/totp/`,
{}
)
}
}

View File

@@ -160,6 +160,18 @@ const user = {
commonAbstractNameFilterPaperlessServiceTests(endpoint, UserService)
describe('Additional service tests for UserService', () => {
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
})
it('should retain permissions on update', () => {
subscription = service.listAll().subscribe()
let req = httpTestingController.expectOne(
@@ -179,15 +191,11 @@ describe('Additional service tests for UserService', () => {
)
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(UserService)
})
afterEach(() => {
subscription?.unsubscribe()
httpTestingController.verify()
it('should deactivate totp', () => {
subscription = service.deactivateTotp(user).subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${user.id}/deactivate_totp/`
)
expect(req.request.method).toEqual('POST')
})
})

View File

@@ -5,6 +5,7 @@ import { User } from 'src/app/data/user'
import { PermissionsService } from '../permissions.service'
import { AbstractNameFilterService } from './abstract-name-filter-service'
const endpoint = 'users'
@Injectable({
providedIn: 'root',
})
@@ -13,7 +14,7 @@ export class UserService extends AbstractNameFilterService<User> {
http: HttpClient,
private permissionService: PermissionsService
) {
super(http, 'users')
super(http, endpoint)
}
update(o: User): Observable<User> {
@@ -31,4 +32,11 @@ export class UserService extends AbstractNameFilterService<User> {
})
)
}
deactivateTotp(u: User): Observable<boolean> {
return this.http.post<boolean>(
`${this.getResourceUrl(u.id, 'deactivate_totp')}`,
null
)
}
}

View File

@@ -48,7 +48,7 @@ describe('TasksService', () => {
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
tasksService.dismissTasks(new Set([1, 2, 3]))
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}acknowledge_tasks/`
`${environment.apiBaseUrl}tasks/acknowledge/`
)
expect(req.request.method).toEqual('POST')
expect(req.request.body).toEqual({

View File

@@ -64,7 +64,7 @@ export class TasksService {
public dismissTasks(task_ids: Set<number>) {
this.http
.post(`${this.baseUrl}acknowledge_tasks/`, {
.post(`${this.baseUrl}tasks/acknowledge/`, {
tasks: [...task_ids],
})
.pipe(first())

View File

@@ -3,7 +3,7 @@ const base_url = new URL(document.baseURI)
export const environment = {
production: true,
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '5',
apiVersion: '6',
appTitle: 'Paperless-ngx',
version: '2.13.5',
webSocketHost: window.location.host,

View File

@@ -5,7 +5,7 @@
export const environment = {
production: false,
apiBaseUrl: 'http://localhost:8000/api/',
apiVersion: '5',
apiVersion: '6',
appTitle: 'Paperless-ngx',
version: 'DEVELOPMENT',
webSocketHost: 'localhost:8000',