mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: two-factor authentication (#8012)
This commit is contained in:
@@ -520,6 +520,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 +580,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 +588,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 +716,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 +1107,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>
|
||||
@@ -1707,7 +1719,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 +1731,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>
|
||||
@@ -2514,7 +2526,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">
|
||||
@@ -2917,21 +2929,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">
|
||||
@@ -3720,7 +3732,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">
|
||||
@@ -4263,7 +4275,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">
|
||||
@@ -4274,7 +4286,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">
|
||||
@@ -4285,7 +4297,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">
|
||||
@@ -4323,18 +4335,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">
|
||||
@@ -5151,32 +5215,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>
|
||||
@@ -5207,14 +5275,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>
|
||||
@@ -5225,91 +5297,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">263</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">265</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">271</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">287</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">289</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">294</context>
|
||||
</context-group>
|
||||
</trans-unit>
|
||||
<trans-unit id="3797570084942068182" datatype="html">
|
||||
|
@@ -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()
|
||||
}
|
||||
|
@@ -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)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user