mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-16 21:55:37 -05:00
Merge branch 'dev' into feature-schedule-wf-trigger
This commit is contained in:
@@ -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 "<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>" 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 "<x id="PH" equiv-text="savedView.name"/>" 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">
|
||||
|
@@ -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,
|
||||
|
@@ -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)
|
||||
|
@@ -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) => {
|
||||
|
@@ -343,6 +343,7 @@ describe('AppFrameComponent', () => {
|
||||
component.editProfile()
|
||||
expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
})
|
||||
})
|
||||
|
||||
|
@@ -136,6 +136,7 @@ export class AppFrameComponent
|
||||
editProfile() {
|
||||
this.modalService.open(ProfileEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
})
|
||||
this.closeMenu()
|
||||
}
|
||||
|
@@ -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> <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> <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> <ng-container i18n>Open</ng-container></span>
|
||||
}
|
||||
</button>
|
||||
|
@@ -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>
|
||||
|
@@ -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()
|
||||
})
|
||||
})
|
||||
|
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -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>
|
||||
|
@@ -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],
|
||||
|
@@ -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) {
|
||||
|
@@ -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}} <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}} <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> <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>
|
||||
<span i18n>Copy codes</span>
|
||||
}
|
||||
@if (codesCopied) {
|
||||
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
|
||||
<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>
|
||||
|
@@ -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)
|
||||
}))
|
||||
})
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -54,7 +54,7 @@
|
||||
<i-bs name="diagram-3"></i-bs> <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> <span class="d-none d-md-inline" i18n>Open</span>
|
||||
<i-bs name="file-earmark-richtext"></i-bs> <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"
|
||||
|
@@ -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"
|
||||
|
@@ -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)
|
||||
})
|
||||
})
|
||||
|
@@ -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() {
|
||||
|
@@ -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',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
|
@@ -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)
|
||||
|
@@ -30,4 +30,6 @@ export interface PaperlessTask extends ObjectWithId {
|
||||
result?: string
|
||||
|
||||
related_document?: number
|
||||
|
||||
owner?: number
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
@@ -11,4 +11,5 @@ export interface User extends ObjectWithId {
|
||||
groups?: number[] // Group[]
|
||||
user_permissions?: string[]
|
||||
inherited_permissions?: string[]
|
||||
is_mfa_enabled?: boolean
|
||||
}
|
||||
|
@@ -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()
|
||||
})
|
||||
})
|
||||
|
@@ -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 ||
|
||||
|
@@ -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')
|
||||
})
|
||||
})
|
||||
|
@@ -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/`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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')
|
||||
})
|
||||
})
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@@ -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({
|
||||
|
@@ -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())
|
||||
|
@@ -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,
|
||||
|
@@ -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',
|
||||
|
Reference in New Issue
Block a user