mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Feature: update user profile (#4678)
This commit is contained in:
		| @@ -21,6 +21,7 @@ The API provides the following main endpoints: | |||||||
| - `/api/groups/`: Full CRUD support. | - `/api/groups/`: Full CRUD support. | ||||||
| - `/api/share_links/`: Full CRUD support. | - `/api/share_links/`: Full CRUD support. | ||||||
| - `/api/custom_fields/`: Full CRUD support. | - `/api/custom_fields/`: Full CRUD support. | ||||||
|  | - `/api/profile/`: GET, PATCH | ||||||
|  |  | ||||||
| All of these endpoints except for the logging endpoint allow you to | All of these endpoints except for the logging endpoint allow you to | ||||||
| fetch (and edit and delete where appropriate) individual objects by | fetch (and edit and delete where appropriate) individual objects by | ||||||
| @@ -157,6 +158,10 @@ The REST api provides three different forms of authentication. | |||||||
|  |  | ||||||
| 3.  Token authentication | 3.  Token authentication | ||||||
|  |  | ||||||
|  |     You can create (or re-create) an API token by opening the "My Profile" | ||||||
|  |     link in the user dropdown found in the web UI and clicking the circular | ||||||
|  |     arrow button. | ||||||
|  |  | ||||||
|     Paperless also offers an endpoint to acquire authentication tokens. |     Paperless also offers an endpoint to acquire authentication tokens. | ||||||
|  |  | ||||||
|     POST a username and password as a form or json string to |     POST a username and password as a form or json string to | ||||||
| @@ -168,7 +173,7 @@ The REST api provides three different forms of authentication. | |||||||
|     Authorization: Token <token> |     Authorization: Token <token> | ||||||
|     ``` |     ``` | ||||||
|  |  | ||||||
|     Tokens can be managed and revoked in the paperless admin. |     Tokens can also be managed in the Django admin. | ||||||
|  |  | ||||||
| ## Searching for documents | ## Searching for documents | ||||||
|  |  | ||||||
|   | |||||||
| @@ -403,11 +403,11 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">225</context> |           <context context-type="linenumber">230</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">228</context> |           <context context-type="linenumber">233</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8838884664569764142" datatype="html"> |       <trans-unit id="8838884664569764142" datatype="html"> | ||||||
| @@ -496,15 +496,15 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">45</context> |           <context context-type="linenumber">50</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">203</context> |           <context context-type="linenumber">208</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">206</context> |           <context context-type="linenumber">211</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1685061484835793745" datatype="html"> |       <trans-unit id="1685061484835793745" datatype="html"> | ||||||
| @@ -973,7 +973,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">91</context> |           <context context-type="linenumber">96</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1595668988802980095" datatype="html"> |       <trans-unit id="1595668988802980095" datatype="html"> | ||||||
| @@ -1329,6 +1329,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">36</context> |           <context context-type="linenumber">36</context> | ||||||
|         </context-group> |         </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">54</context> | ||||||
|  |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> |           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||||
|           <context context-type="linenumber">93</context> |           <context context-type="linenumber">93</context> | ||||||
| @@ -1396,7 +1400,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||||
|           <context context-type="linenumber">119</context> |           <context context-type="linenumber">116</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6839066544204061364" datatype="html"> |       <trans-unit id="6839066544204061364" datatype="html"> | ||||||
| @@ -1428,7 +1432,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">217</context> |           <context context-type="linenumber">222</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="103921551219467537" datatype="html"> |       <trans-unit id="103921551219467537" datatype="html"> | ||||||
| @@ -1630,11 +1634,11 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">210</context> |           <context context-type="linenumber">215</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">213</context> |           <context context-type="linenumber">218</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4555457172864212828" datatype="html"> |       <trans-unit id="4555457172864212828" datatype="html"> | ||||||
| @@ -1780,6 +1784,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> |           <context context-type="sourcefile">src/app/components/admin/users-groups/users-groups.component.ts</context> | ||||||
|           <context context-type="linenumber">89</context> |           <context context-type="linenumber">89</context> | ||||||
|         </context-group> |         </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">144</context> | ||||||
|  |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2753185112875184719" datatype="html"> |       <trans-unit id="2753185112875184719" datatype="html"> | ||||||
|         <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> |         <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> | ||||||
| @@ -1961,37 +1969,44 @@ | |||||||
|           <context context-type="linenumber">39</context> |           <context context-type="linenumber">39</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|  |       <trans-unit id="2127032578120864096" datatype="html"> | ||||||
|  |         <source>My Profile</source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|  |           <context context-type="linenumber">45</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="3797778920049399855" datatype="html"> |       <trans-unit id="3797778920049399855" datatype="html"> | ||||||
|         <source>Logout</source> |         <source>Logout</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">50</context> |           <context context-type="linenumber">55</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4895326106573044490" datatype="html"> |       <trans-unit id="4895326106573044490" datatype="html"> | ||||||
|         <source>Documentation</source> |         <source>Documentation</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">56</context> |           <context context-type="linenumber">61</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">232</context> |           <context context-type="linenumber">237</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">235</context> |           <context context-type="linenumber">240</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="6570363013146073520" datatype="html"> |       <trans-unit id="6570363013146073520" datatype="html"> | ||||||
|         <source>Dashboard</source> |         <source>Dashboard</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">75</context> |           <context context-type="linenumber">80</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">78</context> |           <context context-type="linenumber">83</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> |           <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> | ||||||
| @@ -2002,11 +2017,11 @@ | |||||||
|         <source>Documents</source> |         <source>Documents</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">82</context> |           <context context-type="linenumber">87</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">85</context> |           <context context-type="linenumber">90</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> |           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||||
| @@ -2033,36 +2048,36 @@ | |||||||
|         <source>Open documents</source> |         <source>Open documents</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">118</context> |           <context context-type="linenumber">123</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5687256342387781369" datatype="html"> |       <trans-unit id="5687256342387781369" datatype="html"> | ||||||
|         <source>Close all</source> |         <source>Close all</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">134</context> |           <context context-type="linenumber">139</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">137</context> |           <context context-type="linenumber">142</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3897348120591552265" datatype="html"> |       <trans-unit id="3897348120591552265" datatype="html"> | ||||||
|         <source>Manage</source> |         <source>Manage</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">144</context> |           <context context-type="linenumber">149</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="7437910965833684826" datatype="html"> |       <trans-unit id="7437910965833684826" datatype="html"> | ||||||
|         <source>Correspondents</source> |         <source>Correspondents</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">148</context> |           <context context-type="linenumber">153</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">151</context> |           <context context-type="linenumber">156</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> |           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||||
| @@ -2073,11 +2088,11 @@ | |||||||
|         <source>Tags</source> |         <source>Tags</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">155</context> |           <context context-type="linenumber">160</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">158</context> |           <context context-type="linenumber">163</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> | ||||||
| @@ -2104,11 +2119,11 @@ | |||||||
|         <source>Document Types</source> |         <source>Document Types</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">162</context> |           <context context-type="linenumber">167</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">165</context> |           <context context-type="linenumber">170</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> |           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||||
| @@ -2119,11 +2134,11 @@ | |||||||
|         <source>Storage Paths</source> |         <source>Storage Paths</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">169</context> |           <context context-type="linenumber">174</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">172</context> |           <context context-type="linenumber">177</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> |           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||||
| @@ -2134,11 +2149,11 @@ | |||||||
|         <source>Custom Fields</source> |         <source>Custom Fields</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">176</context> |           <context context-type="linenumber">181</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">179</context> |           <context context-type="linenumber">184</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context> |           <context context-type="sourcefile">src/app/components/common/custom-fields-dropdown/custom-fields-dropdown.component.html</context> | ||||||
| @@ -2153,102 +2168,102 @@ | |||||||
|         <source>Consumption templates</source> |         <source>Consumption templates</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">183</context> |           <context context-type="linenumber">188</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5433675495457939071" datatype="html"> |       <trans-unit id="5433675495457939071" datatype="html"> | ||||||
|         <source>Templates</source> |         <source>Templates</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">186</context> |           <context context-type="linenumber">191</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1292737233370901804" datatype="html"> |       <trans-unit id="1292737233370901804" datatype="html"> | ||||||
|         <source>Mail</source> |         <source>Mail</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">190</context> |           <context context-type="linenumber">195</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">193</context> |           <context context-type="linenumber">198</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="7844706011418789951" datatype="html"> |       <trans-unit id="7844706011418789951" datatype="html"> | ||||||
|         <source>Administration</source> |         <source>Administration</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">199</context> |           <context context-type="linenumber">204</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5537285341303594392" datatype="html"> |       <trans-unit id="5537285341303594392" datatype="html"> | ||||||
|         <source>File Tasks<x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="<span *ngIf="tasksService.failedFileTasks.length > 0">"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> |         <source>File Tasks<x id="START_TAG_SPAN_1" ctype="x-span_1" equiv-text="<span *ngIf="tasksService.failedFileTasks.length > 0">"/><x id="START_TAG_SPAN" ctype="x-span" equiv-text="<span class="badge bg-danger ms-2">"/><x id="INTERPOLATION" equiv-text="{{tasksService.failedFileTasks.length}}"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/><x id="CLOSE_TAG_SPAN" ctype="x-span" equiv-text="</span>"/></source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">221</context> |           <context context-type="linenumber">226</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1534029177398918729" datatype="html"> |       <trans-unit id="1534029177398918729" datatype="html"> | ||||||
|         <source>GitHub</source> |         <source>GitHub</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">241</context> |           <context context-type="linenumber">246</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4112664765954374539" datatype="html"> |       <trans-unit id="4112664765954374539" datatype="html"> | ||||||
|         <source>is available.</source> |         <source>is available.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">247</context> |           <context context-type="linenumber">252</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1175891574282637937" datatype="html"> |       <trans-unit id="1175891574282637937" datatype="html"> | ||||||
|         <source>Click to view.</source> |         <source>Click to view.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">247</context> |           <context context-type="linenumber">252</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="9811291095862612" datatype="html"> |       <trans-unit id="9811291095862612" datatype="html"> | ||||||
|         <source>Paperless-ngx can automatically check for updates</source> |         <source>Paperless-ngx can automatically check for updates</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">251</context> |           <context context-type="linenumber">256</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="894819944961861800" datatype="html"> |       <trans-unit id="894819944961861800" datatype="html"> | ||||||
|         <source> How does this work? </source> |         <source> How does this work? </source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">258,260</context> |           <context context-type="linenumber">263,265</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="509090351011426949" datatype="html"> |       <trans-unit id="509090351011426949" datatype="html"> | ||||||
|         <source>Update available</source> |         <source>Update available</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.html</context> | ||||||
|           <context context-type="linenumber">269</context> |           <context context-type="linenumber">274</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="1542489069631984294" datatype="html"> |       <trans-unit id="1542489069631984294" datatype="html"> | ||||||
|         <source>Sidebar views updated</source> |         <source>Sidebar views updated</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||||
|           <context context-type="linenumber">247</context> |           <context context-type="linenumber">252</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3547923076537026828" datatype="html"> |       <trans-unit id="3547923076537026828" datatype="html"> | ||||||
|         <source>Error updating sidebar views</source> |         <source>Error updating sidebar views</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||||
|           <context context-type="linenumber">250</context> |           <context context-type="linenumber">255</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2526035785704676448" datatype="html"> |       <trans-unit id="2526035785704676448" datatype="html"> | ||||||
|         <source>An error occurred while saving update checking settings.</source> |         <source>An error occurred while saving update checking settings.</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> |           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||||
|           <context context-type="linenumber">271</context> |           <context context-type="linenumber">276</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8700121026680200191" datatype="html"> |       <trans-unit id="8700121026680200191" datatype="html"> | ||||||
| @@ -2623,6 +2638,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/permissions-dialog/permissions-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">20</context> |           <context context-type="linenumber">20</context> | ||||||
|         </context-group> |         </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">53</context> | ||||||
|  |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">12</context> |           <context context-type="linenumber">12</context> | ||||||
| @@ -2843,6 +2862,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">12</context> |           <context context-type="linenumber">12</context> | ||||||
|         </context-group> |         </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-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4249303448466017578" datatype="html"> |       <trans-unit id="4249303448466017578" datatype="html"> | ||||||
|         <source>Password is token</source> |         <source>Password is token</source> | ||||||
| @@ -3307,6 +3330,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">11</context> |           <context context-type="linenumber">11</context> | ||||||
|         </context-group> |         </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-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5342432350421167093" datatype="html"> |       <trans-unit id="5342432350421167093" datatype="html"> | ||||||
|         <source>First name</source> |         <source>First name</source> | ||||||
| @@ -3314,6 +3341,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">13</context> |           <context context-type="linenumber">13</context> | ||||||
|         </context-group> |         </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-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="3586674587150281199" datatype="html"> |       <trans-unit id="3586674587150281199" datatype="html"> | ||||||
|         <source>Last name</source> |         <source>Last name</source> | ||||||
| @@ -3321,6 +3352,10 @@ | |||||||
|           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> |           <context context-type="sourcefile">src/app/components/common/edit-dialog/user-edit-dialog/user-edit-dialog.component.html</context> | ||||||
|           <context context-type="linenumber">14</context> |           <context context-type="linenumber">14</context> | ||||||
|         </context-group> |         </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-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8204176479746810612" datatype="html"> |       <trans-unit id="8204176479746810612" datatype="html"> | ||||||
|         <source>Active</source> |         <source>Active</source> | ||||||
| @@ -3483,6 +3518,13 @@ | |||||||
|           <context context-type="linenumber">155</context> |           <context context-type="linenumber">155</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|  |       <trans-unit id="5554528553553249088" datatype="html"> | ||||||
|  |         <source>Show password</source> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/common/input/password/password.component.html</context> | ||||||
|  |           <context context-type="linenumber">5</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="594042705136125260" datatype="html"> |       <trans-unit id="594042705136125260" datatype="html"> | ||||||
|         <source>Edit Permissions</source> |         <source>Edit Permissions</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
| @@ -3637,6 +3679,109 @@ | |||||||
|           <context context-type="linenumber">61</context> |           <context context-type="linenumber">61</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|  |       <trans-unit id="2984628903434675339" datatype="html"> | ||||||
|  |         <source>Edit Profile</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">3</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|  |       <trans-unit id="8214169742072920158" datatype="html"> | ||||||
|  |         <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-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-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-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-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">39</context> | ||||||
|  |         </context-group> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context> | ||||||
|  |           <context context-type="linenumber">23</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|  |       <trans-unit id="595732867213154214" datatype="html"> | ||||||
|  |         <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">41</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">47</context> | ||||||
|  |         </context-group> | ||||||
|  |         <context-group purpose="location"> | ||||||
|  |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context> | ||||||
|  |           <context context-type="linenumber">36</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|  |       <trans-unit id="4369881772624105142" datatype="html"> | ||||||
|  |         <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">49</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">94</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">122</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">141</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">153</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">170</context> | ||||||
|  |         </context-group> | ||||||
|  |       </trans-unit> | ||||||
|       <trans-unit id="3797570084942068182" datatype="html"> |       <trans-unit id="3797570084942068182" datatype="html"> | ||||||
|         <source>Select</source> |         <source>Select</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
| @@ -3677,13 +3822,6 @@ | |||||||
|           <context context-type="linenumber">10,12</context> |           <context context-type="linenumber">10,12</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="4323470180912194028" datatype="html"> |  | ||||||
|         <source>Copy</source> |  | ||||||
|         <context-group purpose="location"> |  | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context> |  | ||||||
|           <context context-type="linenumber">23</context> |  | ||||||
|         </context-group> |  | ||||||
|       </trans-unit> |  | ||||||
|       <trans-unit id="7419704019640008953" datatype="html"> |       <trans-unit id="7419704019640008953" datatype="html"> | ||||||
|         <source>Share</source> |         <source>Share</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
| @@ -3691,13 +3829,6 @@ | |||||||
|           <context context-type="linenumber">28</context> |           <context context-type="linenumber">28</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5392341774767336507" datatype="html"> |  | ||||||
|         <source>Copied!</source> |  | ||||||
|         <context-group purpose="location"> |  | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.html</context> |  | ||||||
|           <context context-type="linenumber">36</context> |  | ||||||
|         </context-group> |  | ||||||
|       </trans-unit> |  | ||||||
|       <trans-unit id="6811921365829755679" datatype="html"> |       <trans-unit id="6811921365829755679" datatype="html"> | ||||||
|         <source>Share archive version</source> |         <source>Share archive version</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
| @@ -3727,7 +3858,7 @@ | |||||||
|         </context-group> |         </context-group> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> | ||||||
|           <context context-type="linenumber">90</context> |           <context context-type="linenumber">93</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8542568275115626925" datatype="html"> |       <trans-unit id="8542568275115626925" datatype="html"> | ||||||
| @@ -3762,21 +3893,21 @@ | |||||||
|         <source><x id="PH" equiv-text="days"/> days</source> |         <source><x id="PH" equiv-text="days"/> days</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> | ||||||
|           <context context-type="linenumber">90</context> |           <context context-type="linenumber">93</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="2897042887615940599" datatype="html"> |       <trans-unit id="2897042887615940599" datatype="html"> | ||||||
|         <source>Error deleting link</source> |         <source>Error deleting link</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> | ||||||
|           <context context-type="linenumber">117</context> |           <context context-type="linenumber">120</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="8400747326190565173" datatype="html"> |       <trans-unit id="8400747326190565173" datatype="html"> | ||||||
|         <source>Error creating link</source> |         <source>Error creating link</source> | ||||||
|         <context-group purpose="location"> |         <context-group purpose="location"> | ||||||
|           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> |           <context context-type="sourcefile">src/app/components/common/share-links-dropdown/share-links-dropdown.component.ts</context> | ||||||
|           <context context-type="linenumber">145</context> |           <context context-type="linenumber">148</context> | ||||||
|         </context-group> |         </context-group> | ||||||
|       </trans-unit> |       </trans-unit> | ||||||
|       <trans-unit id="5611592591303869712" datatype="html"> |       <trans-unit id="5611592591303869712" datatype="html"> | ||||||
|   | |||||||
| @@ -105,6 +105,7 @@ import { FileDropComponent } from './components/file-drop/file-drop.component' | |||||||
| import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component' | import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component' | ||||||
| import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' | import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' | ||||||
| import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component' | import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component' | ||||||
|  | import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' | ||||||
|  |  | ||||||
| import localeAf from '@angular/common/locales/af' | import localeAf from '@angular/common/locales/af' | ||||||
| import localeAr from '@angular/common/locales/ar' | import localeAr from '@angular/common/locales/ar' | ||||||
| @@ -256,6 +257,7 @@ function initializeApp(settings: SettingsService) { | |||||||
|     CustomFieldsComponent, |     CustomFieldsComponent, | ||||||
|     CustomFieldEditDialogComponent, |     CustomFieldEditDialogComponent, | ||||||
|     CustomFieldsDropdownComponent, |     CustomFieldsDropdownComponent, | ||||||
|  |     ProfileEditDialogComponent, | ||||||
|   ], |   ], | ||||||
|   imports: [ |   imports: [ | ||||||
|     BrowserModule, |     BrowserModule, | ||||||
|   | |||||||
| @@ -89,7 +89,7 @@ export class UsersAndGroupsComponent | |||||||
|             $localize`Password has been changed, you will be logged out momentarily.` |             $localize`Password has been changed, you will be logged out momentarily.` | ||||||
|           ) |           ) | ||||||
|           setTimeout(() => { |           setTimeout(() => { | ||||||
|             window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/` |             window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||||
|           }, 2500) |           }, 2500) | ||||||
|         } else { |         } else { | ||||||
|           this.toastService.showInfo( |           this.toastService.showInfo( | ||||||
|   | |||||||
| @@ -39,6 +39,11 @@ | |||||||
|           <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p> |           <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p> | ||||||
|           <div class="dropdown-divider"></div> |           <div class="dropdown-divider"></div> | ||||||
|         </div> |         </div> | ||||||
|  |         <button ngbDropdownItem class="nav-link" (click)="editProfile()"> | ||||||
|  |           <svg class="sidebaricon me-2" fill="currentColor"> | ||||||
|  |             <use xlink:href="assets/bootstrap-icons.svg#person"/> | ||||||
|  |           </svg><ng-container i18n>My Profile</ng-container> | ||||||
|  |         </button> | ||||||
|         <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"> |         <a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"> | ||||||
|           <svg class="sidebaricon me-2" fill="currentColor"> |           <svg class="sidebaricon me-2" fill="currentColor"> | ||||||
|             <use xlink:href="assets/bootstrap-icons.svg#gear"/> |             <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ import { | |||||||
|   fakeAsync, |   fakeAsync, | ||||||
|   tick, |   tick, | ||||||
| } from '@angular/core/testing' | } from '@angular/core/testing' | ||||||
| import { NgbModule } from '@ng-bootstrap/ng-bootstrap' | import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap' | ||||||
| import { BrowserModule } from '@angular/platform-browser' | import { BrowserModule } from '@angular/platform-browser' | ||||||
| import { RouterTestingModule } from '@angular/router/testing' | import { RouterTestingModule } from '@angular/router/testing' | ||||||
| import { SettingsService } from 'src/app/services/settings.service' | import { SettingsService } from 'src/app/services/settings.service' | ||||||
| @@ -32,6 +32,7 @@ import { routes } from 'src/app/app-routing.module' | |||||||
| import { PermissionsGuard } from 'src/app/guards/permissions.guard' | import { PermissionsGuard } from 'src/app/guards/permissions.guard' | ||||||
| import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop' | import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop' | ||||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||||
|  | import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||||
|  |  | ||||||
| const saved_views = [ | const saved_views = [ | ||||||
|   { |   { | ||||||
| @@ -86,6 +87,7 @@ describe('AppFrameComponent', () => { | |||||||
|   let documentListViewService: DocumentListViewService |   let documentListViewService: DocumentListViewService | ||||||
|   let router: Router |   let router: Router | ||||||
|   let savedViewSpy |   let savedViewSpy | ||||||
|  |   let modalService: NgbModal | ||||||
|  |  | ||||||
|   beforeEach(async () => { |   beforeEach(async () => { | ||||||
|     TestBed.configureTestingModule({ |     TestBed.configureTestingModule({ | ||||||
| @@ -98,6 +100,7 @@ describe('AppFrameComponent', () => { | |||||||
|         FormsModule, |         FormsModule, | ||||||
|         ReactiveFormsModule, |         ReactiveFormsModule, | ||||||
|         DragDropModule, |         DragDropModule, | ||||||
|  |         NgbModalModule, | ||||||
|       ], |       ], | ||||||
|       providers: [ |       providers: [ | ||||||
|         SettingsService, |         SettingsService, | ||||||
| @@ -120,6 +123,7 @@ describe('AppFrameComponent', () => { | |||||||
|         ToastService, |         ToastService, | ||||||
|         OpenDocumentsService, |         OpenDocumentsService, | ||||||
|         SearchService, |         SearchService, | ||||||
|  |         NgbModal, | ||||||
|         { |         { | ||||||
|           provide: ActivatedRoute, |           provide: ActivatedRoute, | ||||||
|           useValue: { |           useValue: { | ||||||
| @@ -148,6 +152,7 @@ describe('AppFrameComponent', () => { | |||||||
|     openDocumentsService = TestBed.inject(OpenDocumentsService) |     openDocumentsService = TestBed.inject(OpenDocumentsService) | ||||||
|     searchService = TestBed.inject(SearchService) |     searchService = TestBed.inject(SearchService) | ||||||
|     documentListViewService = TestBed.inject(DocumentListViewService) |     documentListViewService = TestBed.inject(DocumentListViewService) | ||||||
|  |     modalService = TestBed.inject(NgbModal) | ||||||
|     router = TestBed.inject(Router) |     router = TestBed.inject(Router) | ||||||
|  |  | ||||||
|     jest |     jest | ||||||
| @@ -363,4 +368,12 @@ describe('AppFrameComponent', () => { | |||||||
|     >) |     >) | ||||||
|     expect(toastSpy).toHaveBeenCalled() |     expect(toastSpy).toHaveBeenCalled() | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|  |   it('should support edit profile', () => { | ||||||
|  |     const modalSpy = jest.spyOn(modalService, 'open') | ||||||
|  |     component.editProfile() | ||||||
|  |     expect(modalSpy).toHaveBeenCalledWith(ProfileEditDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |     }) | ||||||
|  |   }) | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -39,6 +39,8 @@ import { | |||||||
|   CdkDragDrop, |   CdkDragDrop, | ||||||
|   moveItemInArray, |   moveItemInArray, | ||||||
| } from '@angular/cdk/drag-drop' | } from '@angular/cdk/drag-drop' | ||||||
|  | import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||||
|  |  | ||||||
| @Component({ | @Component({ | ||||||
|   selector: 'pngx-app-frame', |   selector: 'pngx-app-frame', | ||||||
| @@ -69,6 +71,7 @@ export class AppFrameComponent | |||||||
|     public settingsService: SettingsService, |     public settingsService: SettingsService, | ||||||
|     public tasksService: TasksService, |     public tasksService: TasksService, | ||||||
|     private readonly toastService: ToastService, |     private readonly toastService: ToastService, | ||||||
|  |     private modalService: NgbModal, | ||||||
|     permissionsService: PermissionsService |     permissionsService: PermissionsService | ||||||
|   ) { |   ) { | ||||||
|     super() |     super() | ||||||
| @@ -121,6 +124,13 @@ export class AppFrameComponent | |||||||
|     this.isMenuCollapsed = true |     this.isMenuCollapsed = true | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   editProfile() { | ||||||
|  |     this.modalService.open(ProfileEditDialogComponent, { | ||||||
|  |       backdrop: 'static', | ||||||
|  |     }) | ||||||
|  |     this.closeMenu() | ||||||
|  |   } | ||||||
|  |  | ||||||
|   get openDocuments(): PaperlessDocument[] { |   get openDocuments(): PaperlessDocument[] { | ||||||
|     return this.openDocumentsService.getOpenDocuments() |     return this.openDocumentsService.getOpenDocuments() | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -1,8 +1,15 @@ | |||||||
| <div class="mb-3"> | <div class="mb-3"> | ||||||
|   <label class="form-label" [for]="inputId">{{title}}</label> |   <label class="form-label" [for]="inputId">{{title}}</label> | ||||||
|   <input #inputField type="password" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)"> |   <div class="input-group" [class.is-invalid]="error"> | ||||||
|   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> |     <input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete"> | ||||||
|  |     <button *ngIf="showReveal" type="button" class="btn btn-outline-secondary" (click)="toggleVisibility()" i18n-title title="Show password" [disabled]="disabled || disableRevealToggle"> | ||||||
|  |       <svg width="1em" height="1em" viewBox="0 0 16 16" fill="currentColor"> | ||||||
|  |         <use xlink:href="assets/bootstrap-icons.svg#eye" /> | ||||||
|  |       </svg> | ||||||
|  |     </button> | ||||||
|  |   </div> | ||||||
|   <div class="invalid-feedback"> |   <div class="invalid-feedback"> | ||||||
|     {{error}} |     {{error}} | ||||||
|   </div> |   </div> | ||||||
|  |   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||||
| </div> | </div> | ||||||
|   | |||||||
| @@ -5,6 +5,7 @@ import { | |||||||
|   NG_VALUE_ACCESSOR, |   NG_VALUE_ACCESSOR, | ||||||
| } from '@angular/forms' | } from '@angular/forms' | ||||||
| import { PasswordComponent } from './password.component' | import { PasswordComponent } from './password.component' | ||||||
|  | import { By } from '@angular/platform-browser' | ||||||
|  |  | ||||||
| describe('PasswordComponent', () => { | describe('PasswordComponent', () => { | ||||||
|   let component: PasswordComponent |   let component: PasswordComponent | ||||||
| @@ -33,4 +34,26 @@ describe('PasswordComponent', () => { | |||||||
|     // fixture.detectChanges() |     // fixture.detectChanges() | ||||||
|     // expect(component.value).toEqual('foo') |     // expect(component.value).toEqual('foo') | ||||||
|   }) |   }) | ||||||
|  |  | ||||||
|  |   it('should support toggling field visibility', () => { | ||||||
|  |     expect(input.type).toEqual('password') | ||||||
|  |     component.showReveal = true | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     fixture.debugElement.query(By.css('button')).triggerEventHandler('click') | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(input.type).toEqual('text') | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should empty field if password is obfuscated on focus', () => { | ||||||
|  |     component.value = '*********' | ||||||
|  |     component.onFocus() | ||||||
|  |     expect(component.value).toEqual('') | ||||||
|  |     component.onFocusOut() | ||||||
|  |     expect(component.value).toEqual('**********') | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should disable toggle button if no real password', () => { | ||||||
|  |     component.value = '*********' | ||||||
|  |     expect(component.disableRevealToggle).toBeTruthy() | ||||||
|  |   }) | ||||||
| }) | }) | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, forwardRef } from '@angular/core' | import { Component, Input, forwardRef } from '@angular/core' | ||||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||||
| import { AbstractInputComponent } from '../abstract-input' | import { AbstractInputComponent } from '../abstract-input' | ||||||
|  |  | ||||||
| @@ -15,7 +15,32 @@ import { AbstractInputComponent } from '../abstract-input' | |||||||
|   styleUrls: ['./password.component.scss'], |   styleUrls: ['./password.component.scss'], | ||||||
| }) | }) | ||||||
| export class PasswordComponent extends AbstractInputComponent<string> { | export class PasswordComponent extends AbstractInputComponent<string> { | ||||||
|   constructor() { |   @Input() | ||||||
|     super() |   showReveal: boolean = false | ||||||
|  |  | ||||||
|  |   @Input() | ||||||
|  |   autocomplete: string | ||||||
|  |  | ||||||
|  |   public textVisible: boolean = false | ||||||
|  |  | ||||||
|  |   public toggleVisibility(): void { | ||||||
|  |     this.textVisible = !this.textVisible | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public onFocus() { | ||||||
|  |     if (this.value?.replace(/\*/g, '').length === 0) { | ||||||
|  |       this.writeValue('') | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   public onFocusOut() { | ||||||
|  |     if (this.value?.length === 0) { | ||||||
|  |       this.writeValue('**********') | ||||||
|  |       this.onChange(this.value) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get disableRevealToggle(): boolean { | ||||||
|  |     return this.value?.replace(/\*/g, '').length === 0 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -9,7 +9,7 @@ | |||||||
|       </button> |       </button> | ||||||
|     </div> |     </div> | ||||||
|     <div class="position-relative" [class.col-md-9]="horizontal"> |     <div class="position-relative" [class.col-md-9]="horizontal"> | ||||||
|       <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled"> |       <input #inputField type="text" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete"> | ||||||
|       <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> |       <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||||
|       <div class="invalid-feedback position-absolute top-100"> |       <div class="invalid-feedback position-absolute top-100"> | ||||||
|         {{error}} |         {{error}} | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import { Component, forwardRef } from '@angular/core' | import { Component, Input, forwardRef } from '@angular/core' | ||||||
| import { NG_VALUE_ACCESSOR } from '@angular/forms' | import { NG_VALUE_ACCESSOR } from '@angular/forms' | ||||||
| import { AbstractInputComponent } from '../abstract-input' | import { AbstractInputComponent } from '../abstract-input' | ||||||
|  |  | ||||||
| @@ -15,6 +15,9 @@ import { AbstractInputComponent } from '../abstract-input' | |||||||
|   styleUrls: ['./text.component.scss'], |   styleUrls: ['./text.component.scss'], | ||||||
| }) | }) | ||||||
| export class TextComponent extends AbstractInputComponent<string> { | export class TextComponent extends AbstractInputComponent<string> { | ||||||
|  |   @Input() | ||||||
|  |   autocomplete: string | ||||||
|  |  | ||||||
|   constructor() { |   constructor() { | ||||||
|     super() |     super() | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | <form [formGroup]="form" (ngSubmit)="save()" autocomplete="off"> | ||||||
|  |     <div class="modal-header"> | ||||||
|  |       <h4 class="modal-title" id="modal-basic-title" i18n>Edit Profile</h4> | ||||||
|  |       <button type="button" class="btn-close" aria-label="Close" (click)="cancel()"> | ||||||
|  |       </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> | ||||||
|  |             </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> | ||||||
|  |               </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"> | ||||||
|  |                 <svg class="buttonicon-sm" fill="currentColor"> | ||||||
|  |                   <use *ngIf="!copied" xlink:href="assets/bootstrap-icons.svg#clipboard-fill" /> | ||||||
|  |                   <use *ngIf="copied" xlink:href="assets/bootstrap-icons.svg#clipboard-check-fill" /> | ||||||
|  |                 </svg><span class="visually-hidden" i18n>Copy</span> | ||||||
|  |               </button> | ||||||
|  |               <button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token"> | ||||||
|  |                 <svg class="buttonicon" fill="currentColor"> | ||||||
|  |                   <use xlink:href="assets/bootstrap-icons.svg#arrow-repeat" /> | ||||||
|  |                 </svg> | ||||||
|  |               </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> | ||||||
|  |     </div> | ||||||
|  |     <div class="modal-footer"> | ||||||
|  |       <button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button> | ||||||
|  |       <button type="submit" class="btn btn-primary" i18n [disabled]="networkActive || saveDisabled">Save</button> | ||||||
|  |     </div> | ||||||
|  |   </form> | ||||||
| @@ -0,0 +1,9 @@ | |||||||
|  | ::ng-deep { | ||||||
|  |     .accordion-body .mb-3 { | ||||||
|  |         margin: 0 !important; // hack-ish, for animation | ||||||
|  |     } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | .copied-badge { | ||||||
|  |     right: 8em; | ||||||
|  | } | ||||||
| @@ -0,0 +1,222 @@ | |||||||
|  | import { | ||||||
|  |   ComponentFixture, | ||||||
|  |   TestBed, | ||||||
|  |   fakeAsync, | ||||||
|  |   tick, | ||||||
|  | } from '@angular/core/testing' | ||||||
|  |  | ||||||
|  | import { ProfileEditDialogComponent } from './profile-edit-dialog.component' | ||||||
|  | import { ProfileService } from 'src/app/services/profile.service' | ||||||
|  | import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||||
|  | import { | ||||||
|  |   NgbAccordionModule, | ||||||
|  |   NgbActiveModal, | ||||||
|  |   NgbModal, | ||||||
|  |   NgbModalModule, | ||||||
|  | } from '@ng-bootstrap/ng-bootstrap' | ||||||
|  | import { HttpClientModule } from '@angular/common/http' | ||||||
|  | import { TextComponent } from '../input/text/text.component' | ||||||
|  | import { PasswordComponent } from '../input/password/password.component' | ||||||
|  | import { of, throwError } from 'rxjs' | ||||||
|  | import { ToastService } from 'src/app/services/toast.service' | ||||||
|  | import { Clipboard } from '@angular/cdk/clipboard' | ||||||
|  |  | ||||||
|  | const profile = { | ||||||
|  |   email: 'foo@bar.com', | ||||||
|  |   password: '*********', | ||||||
|  |   first_name: 'foo', | ||||||
|  |   last_name: 'bar', | ||||||
|  |   auth_token: '123456789abcdef', | ||||||
|  | } | ||||||
|  |  | ||||||
|  | describe('ProfileEditDialogComponent', () => { | ||||||
|  |   let component: ProfileEditDialogComponent | ||||||
|  |   let fixture: ComponentFixture<ProfileEditDialogComponent> | ||||||
|  |   let profileService: ProfileService | ||||||
|  |   let toastService: ToastService | ||||||
|  |   let clipboard: Clipboard | ||||||
|  |  | ||||||
|  |   beforeEach(() => { | ||||||
|  |     TestBed.configureTestingModule({ | ||||||
|  |       declarations: [ | ||||||
|  |         ProfileEditDialogComponent, | ||||||
|  |         TextComponent, | ||||||
|  |         PasswordComponent, | ||||||
|  |       ], | ||||||
|  |       providers: [NgbActiveModal], | ||||||
|  |       imports: [ | ||||||
|  |         HttpClientModule, | ||||||
|  |         ReactiveFormsModule, | ||||||
|  |         FormsModule, | ||||||
|  |         NgbModalModule, | ||||||
|  |         NgbAccordionModule, | ||||||
|  |       ], | ||||||
|  |     }) | ||||||
|  |     profileService = TestBed.inject(ProfileService) | ||||||
|  |     toastService = TestBed.inject(ToastService) | ||||||
|  |     clipboard = TestBed.inject(Clipboard) | ||||||
|  |     fixture = TestBed.createComponent(ProfileEditDialogComponent) | ||||||
|  |     component = fixture.componentInstance | ||||||
|  |     fixture.detectChanges() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should get profile on init, display in form', () => { | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |     component.ngOnInit() | ||||||
|  |     expect(getSpy).toHaveBeenCalled() | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(component.form.get('email').value).toEqual(profile.email) | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should update profile on save, display error if needed', () => { | ||||||
|  |     const newProfile = { | ||||||
|  |       email: 'foo@bar2.com', | ||||||
|  |       password: profile.password, | ||||||
|  |       first_name: 'foo2', | ||||||
|  |       last_name: profile.last_name, | ||||||
|  |       auth_token: profile.auth_token, | ||||||
|  |     } | ||||||
|  |     const updateSpy = jest.spyOn(profileService, 'update') | ||||||
|  |     const errorSpy = jest.spyOn(toastService, 'showError') | ||||||
|  |     updateSpy.mockReturnValueOnce(throwError(() => new Error('failed to save'))) | ||||||
|  |     component.save() | ||||||
|  |     expect(errorSpy).toHaveBeenCalled() | ||||||
|  |  | ||||||
|  |     updateSpy.mockClear() | ||||||
|  |     const infoSpy = jest.spyOn(toastService, 'showInfo') | ||||||
|  |     component.form.patchValue(newProfile) | ||||||
|  |     updateSpy.mockReturnValueOnce(of(newProfile)) | ||||||
|  |     component.save() | ||||||
|  |     expect(updateSpy).toHaveBeenCalledWith(newProfile) | ||||||
|  |     expect(infoSpy).toHaveBeenCalled() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should close on cancel', () => { | ||||||
|  |     const closeSpy = jest.spyOn(component.activeModal, 'close') | ||||||
|  |     component.cancel() | ||||||
|  |     expect(closeSpy).toHaveBeenCalled() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should show additional confirmation field when email changes, warn with error & disable save', () => { | ||||||
|  |     expect(component.form.get('email_confirm').enabled).toBeFalsy() | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |     component.ngOnInit() | ||||||
|  |     component.form.get('email').patchValue('foo@bar2.com') | ||||||
|  |     component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any) | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(component.form.get('email_confirm').enabled).toBeTruthy() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).toContain( | ||||||
|  |       'Emails must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeTruthy() | ||||||
|  |  | ||||||
|  |     component.form.get('email_confirm').patchValue('foo@bar2.com') | ||||||
|  |     component.onEmailConfirmKeyUp({ target: { value: 'foo@bar2.com' } } as any) | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).not.toContain( | ||||||
|  |       'Emails must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeFalsy() | ||||||
|  |  | ||||||
|  |     component.form.get('email').patchValue(profile.email) | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(component.form.get('email_confirm').enabled).toBeFalsy() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).not.toContain( | ||||||
|  |       'Emails must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeFalsy() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should show additional confirmation field when password changes, warn with error & disable save', () => { | ||||||
|  |     expect(component.form.get('password_confirm').enabled).toBeFalsy() | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |     component.ngOnInit() | ||||||
|  |     component.form.get('password').patchValue('new*pass') | ||||||
|  |     component.onPasswordKeyUp({ | ||||||
|  |       target: { value: 'new*pass', tagName: 'input' }, | ||||||
|  |     } as any) | ||||||
|  |     component.onPasswordKeyUp({ target: { tagName: 'button' } } as any) // coverage | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(component.form.get('password_confirm').enabled).toBeTruthy() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).toContain( | ||||||
|  |       'Passwords must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeTruthy() | ||||||
|  |  | ||||||
|  |     component.form.get('password_confirm').patchValue('new*pass') | ||||||
|  |     component.onPasswordConfirmKeyUp({ target: { value: 'new*pass' } } as any) | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).not.toContain( | ||||||
|  |       'Passwords must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeFalsy() | ||||||
|  |  | ||||||
|  |     component.form.get('password').patchValue(profile.password) | ||||||
|  |     fixture.detectChanges() | ||||||
|  |     expect(component.form.get('password_confirm').enabled).toBeFalsy() | ||||||
|  |     expect(fixture.debugElement.nativeElement.textContent).not.toContain( | ||||||
|  |       'Passwords must match' | ||||||
|  |     ) | ||||||
|  |     expect(component.saveDisabled).toBeFalsy() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('should logout on save if password changed', fakeAsync(() => { | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |     component.ngOnInit() | ||||||
|  |     component['newPassword'] = 'new*pass' | ||||||
|  |     component.form.get('password').patchValue('new*pass') | ||||||
|  |     component.form.get('password_confirm').patchValue('new*pass') | ||||||
|  |  | ||||||
|  |     const updateSpy = jest.spyOn(profileService, 'update') | ||||||
|  |     updateSpy.mockReturnValue(of(null)) | ||||||
|  |     Object.defineProperty(window, 'location', { | ||||||
|  |       value: { | ||||||
|  |         href: 'http://localhost/', | ||||||
|  |       }, | ||||||
|  |       writable: true, // possibility to override | ||||||
|  |     }) | ||||||
|  |     component.save() | ||||||
|  |     expect(updateSpy).toHaveBeenCalled() | ||||||
|  |     tick(2600) | ||||||
|  |     expect(window.location.href).toContain('logout') | ||||||
|  |   })) | ||||||
|  |  | ||||||
|  |   it('should support auth token copy', fakeAsync(() => { | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |     component.ngOnInit() | ||||||
|  |     const copySpy = jest.spyOn(clipboard, 'copy') | ||||||
|  |     component.copyAuthToken() | ||||||
|  |     expect(copySpy).toHaveBeenCalledWith(profile.auth_token) | ||||||
|  |     expect(component.copied).toBeTruthy() | ||||||
|  |     tick(3000) | ||||||
|  |     expect(component.copied).toBeFalsy() | ||||||
|  |   })) | ||||||
|  |  | ||||||
|  |   it('should support generate token, display error if needed', () => { | ||||||
|  |     const getSpy = jest.spyOn(profileService, 'get') | ||||||
|  |     getSpy.mockReturnValue(of(profile)) | ||||||
|  |  | ||||||
|  |     const generateSpy = jest.spyOn(profileService, 'generateAuthToken') | ||||||
|  |     const errorSpy = jest.spyOn(toastService, 'showError') | ||||||
|  |     generateSpy.mockReturnValueOnce( | ||||||
|  |       throwError(() => new Error('failed to generate')) | ||||||
|  |     ) | ||||||
|  |     component.generateAuthToken() | ||||||
|  |     expect(errorSpy).toHaveBeenCalled() | ||||||
|  |  | ||||||
|  |     generateSpy.mockClear() | ||||||
|  |     const newToken = '789101112hijk' | ||||||
|  |     generateSpy.mockReturnValueOnce(of(newToken)) | ||||||
|  |     component.generateAuthToken() | ||||||
|  |     expect(generateSpy).toHaveBeenCalled() | ||||||
|  |     expect(component.form.get('auth_token').value).not.toEqual( | ||||||
|  |       profile.auth_token | ||||||
|  |     ) | ||||||
|  |     expect(component.form.get('auth_token').value).toEqual(newToken) | ||||||
|  |   }) | ||||||
|  | }) | ||||||
| @@ -0,0 +1,184 @@ | |||||||
|  | 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 { ToastService } from 'src/app/services/toast.service' | ||||||
|  | import { Subject, takeUntil } from 'rxjs' | ||||||
|  | import { Clipboard } from '@angular/cdk/clipboard' | ||||||
|  |  | ||||||
|  | @Component({ | ||||||
|  |   selector: 'pngx-profile-edit-dialog', | ||||||
|  |   templateUrl: './profile-edit-dialog.component.html', | ||||||
|  |   styleUrls: ['./profile-edit-dialog.component.scss'], | ||||||
|  | }) | ||||||
|  | export class ProfileEditDialogComponent implements OnInit, OnDestroy { | ||||||
|  |   public networkActive: boolean = false | ||||||
|  |   public error: any | ||||||
|  |   private unsubscribeNotifier: Subject<any> = new Subject() | ||||||
|  |  | ||||||
|  |   public form = new FormGroup({ | ||||||
|  |     email: new FormControl(''), | ||||||
|  |     email_confirm: new FormControl({ value: null, disabled: true }), | ||||||
|  |     password: new FormControl(null), | ||||||
|  |     password_confirm: new FormControl({ value: null, disabled: true }), | ||||||
|  |     first_name: new FormControl(''), | ||||||
|  |     last_name: new FormControl(''), | ||||||
|  |     auth_token: new FormControl(''), | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   private currentPassword: string | ||||||
|  |   private newPassword: string | ||||||
|  |   private passwordConfirm: string | ||||||
|  |   public showPasswordConfirm: boolean = false | ||||||
|  |  | ||||||
|  |   private currentEmail: string | ||||||
|  |   private newEmail: string | ||||||
|  |   private emailConfirm: string | ||||||
|  |   public showEmailConfirm: boolean = false | ||||||
|  |  | ||||||
|  |   public copied: boolean = false | ||||||
|  |  | ||||||
|  |   constructor( | ||||||
|  |     private profileService: ProfileService, | ||||||
|  |     public activeModal: NgbActiveModal, | ||||||
|  |     private toastService: ToastService, | ||||||
|  |     private clipboard: Clipboard | ||||||
|  |   ) {} | ||||||
|  |  | ||||||
|  |   ngOnInit(): void { | ||||||
|  |     this.networkActive = true | ||||||
|  |     this.profileService | ||||||
|  |       .get() | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe((profile) => { | ||||||
|  |         this.networkActive = false | ||||||
|  |         this.form.patchValue(profile) | ||||||
|  |         this.currentEmail = profile.email | ||||||
|  |         this.form.get('email').valueChanges.subscribe((newEmail) => { | ||||||
|  |           this.newEmail = newEmail | ||||||
|  |           this.onEmailChange() | ||||||
|  |         }) | ||||||
|  |         this.currentPassword = profile.password | ||||||
|  |         this.form.get('password').valueChanges.subscribe((newPassword) => { | ||||||
|  |           this.newPassword = newPassword | ||||||
|  |           this.onPasswordChange() | ||||||
|  |         }) | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   ngOnDestroy(): void { | ||||||
|  |     this.unsubscribeNotifier.next(true) | ||||||
|  |     this.unsubscribeNotifier.complete() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get saveDisabled(): boolean { | ||||||
|  |     return this.error?.password_confirm || this.error?.email_confirm | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onEmailKeyUp(event: KeyboardEvent): void { | ||||||
|  |     this.newEmail = (event.target as HTMLInputElement)?.value | ||||||
|  |     this.onEmailChange() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onEmailConfirmKeyUp(event: KeyboardEvent): void { | ||||||
|  |     this.emailConfirm = (event.target as HTMLInputElement)?.value | ||||||
|  |     this.onEmailChange() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onEmailChange(): void { | ||||||
|  |     this.showEmailConfirm = this.currentEmail !== this.newEmail | ||||||
|  |     if (this.showEmailConfirm) { | ||||||
|  |       this.form.get('email_confirm').enable() | ||||||
|  |       if (this.newEmail !== this.emailConfirm) { | ||||||
|  |         if (!this.error) this.error = {} | ||||||
|  |         this.error.email_confirm = $localize`Emails must match` | ||||||
|  |       } else { | ||||||
|  |         delete this.error?.email_confirm | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       this.form.get('email_confirm').disable() | ||||||
|  |       delete this.error?.email_confirm | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onPasswordKeyUp(event: KeyboardEvent): void { | ||||||
|  |     if ((event.target as HTMLElement).tagName !== 'input') return // toggle button can trigger this handler | ||||||
|  |     this.newPassword = (event.target as HTMLInputElement)?.value | ||||||
|  |     this.onPasswordChange() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onPasswordConfirmKeyUp(event: KeyboardEvent): void { | ||||||
|  |     this.passwordConfirm = (event.target as HTMLInputElement)?.value | ||||||
|  |     this.onPasswordChange() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   onPasswordChange(): void { | ||||||
|  |     this.showPasswordConfirm = this.currentPassword !== this.newPassword | ||||||
|  |  | ||||||
|  |     if (this.showPasswordConfirm) { | ||||||
|  |       this.form.get('password_confirm').enable() | ||||||
|  |       if (this.newPassword !== this.passwordConfirm) { | ||||||
|  |         if (!this.error) this.error = {} | ||||||
|  |         this.error.password_confirm = $localize`Passwords must match` | ||||||
|  |       } else { | ||||||
|  |         delete this.error?.password_confirm | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       this.form.get('password_confirm').disable() | ||||||
|  |       delete this.error?.password_confirm | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   save(): void { | ||||||
|  |     const passwordChanged = this.currentPassword !== this.newPassword | ||||||
|  |     const profile = Object.assign({}, this.form.value) | ||||||
|  |     this.networkActive = true | ||||||
|  |     this.profileService | ||||||
|  |       .update(profile) | ||||||
|  |       .pipe(takeUntil(this.unsubscribeNotifier)) | ||||||
|  |       .subscribe({ | ||||||
|  |         next: () => { | ||||||
|  |           this.toastService.showInfo($localize`Profile updated successfully`) | ||||||
|  |           if (passwordChanged) { | ||||||
|  |             this.toastService.showInfo( | ||||||
|  |               $localize`Password has been changed, you will be logged out momentarily.` | ||||||
|  |             ) | ||||||
|  |             setTimeout(() => { | ||||||
|  |               window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` | ||||||
|  |             }, 2500) | ||||||
|  |           } | ||||||
|  |           this.activeModal.close() | ||||||
|  |         }, | ||||||
|  |         error: (error) => { | ||||||
|  |           this.toastService.showError($localize`Error saving profile`, error) | ||||||
|  |           this.networkActive = false | ||||||
|  |         }, | ||||||
|  |       }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   cancel(): void { | ||||||
|  |     this.activeModal.close() | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   generateAuthToken(): void { | ||||||
|  |     this.profileService.generateAuthToken().subscribe({ | ||||||
|  |       next: (token: string) => { | ||||||
|  |         this.form.patchValue({ auth_token: token }) | ||||||
|  |       }, | ||||||
|  |       error: (error) => { | ||||||
|  |         this.toastService.showError( | ||||||
|  |           $localize`Error generating auth token`, | ||||||
|  |           error | ||||||
|  |         ) | ||||||
|  |       }, | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   copyAuthToken(): void { | ||||||
|  |     this.clipboard.copy(this.form.get('auth_token').value) | ||||||
|  |     this.copied = true | ||||||
|  |     setTimeout(() => { | ||||||
|  |       this.copied = false | ||||||
|  |     }, 3000) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										7
									
								
								src-ui/src/app/data/user-profile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src-ui/src/app/data/user-profile.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | export interface PaperlessUserProfile { | ||||||
|  |   email?: string | ||||||
|  |   password?: string | ||||||
|  |   first_name?: string | ||||||
|  |   last_name?: string | ||||||
|  |   auth_token?: string | ||||||
|  | } | ||||||
							
								
								
									
										54
									
								
								src-ui/src/app/services/profile.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										54
									
								
								src-ui/src/app/services/profile.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,54 @@ | |||||||
|  | import { TestBed } from '@angular/core/testing' | ||||||
|  |  | ||||||
|  | import { ProfileService } from './profile.service' | ||||||
|  | import { | ||||||
|  |   HttpClientTestingModule, | ||||||
|  |   HttpTestingController, | ||||||
|  | } from '@angular/common/http/testing' | ||||||
|  | import { environment } from 'src/environments/environment' | ||||||
|  |  | ||||||
|  | describe('ProfileService', () => { | ||||||
|  |   let httpTestingController: HttpTestingController | ||||||
|  |   let service: ProfileService | ||||||
|  |  | ||||||
|  |   beforeEach(() => { | ||||||
|  |     TestBed.configureTestingModule({ | ||||||
|  |       providers: [ProfileService], | ||||||
|  |       imports: [HttpClientTestingModule], | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     httpTestingController = TestBed.inject(HttpTestingController) | ||||||
|  |     service = TestBed.inject(ProfileService) | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   afterEach(() => { | ||||||
|  |     httpTestingController.verify() | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('calls get profile endpoint', () => { | ||||||
|  |     service.get().subscribe() | ||||||
|  |     const req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}profile/` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('GET') | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('calls patch on update', () => { | ||||||
|  |     service.update({ email: 'foo@bar.com' }).subscribe() | ||||||
|  |     const req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}profile/` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('PATCH') | ||||||
|  |     expect(req.request.body).toEqual({ | ||||||
|  |       email: 'foo@bar.com', | ||||||
|  |     }) | ||||||
|  |   }) | ||||||
|  |  | ||||||
|  |   it('supports generating new auth token', () => { | ||||||
|  |     service.generateAuthToken().subscribe() | ||||||
|  |     const req = httpTestingController.expectOne( | ||||||
|  |       `${environment.apiBaseUrl}profile/generate_auth_token/` | ||||||
|  |     ) | ||||||
|  |     expect(req.request.method).toEqual('POST') | ||||||
|  |   }) | ||||||
|  | }) | ||||||
							
								
								
									
										34
									
								
								src-ui/src/app/services/profile.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src-ui/src/app/services/profile.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | |||||||
|  | import { HttpClient } from '@angular/common/http' | ||||||
|  | import { Injectable } from '@angular/core' | ||||||
|  | import { Observable } from 'rxjs' | ||||||
|  | import { PaperlessUserProfile } from '../data/user-profile' | ||||||
|  | import { environment } from 'src/environments/environment' | ||||||
|  |  | ||||||
|  | @Injectable({ | ||||||
|  |   providedIn: 'root', | ||||||
|  | }) | ||||||
|  | export class ProfileService { | ||||||
|  |   private endpoint = 'profile' | ||||||
|  |  | ||||||
|  |   constructor(private http: HttpClient) {} | ||||||
|  |  | ||||||
|  |   get(): Observable<PaperlessUserProfile> { | ||||||
|  |     return this.http.get<PaperlessUserProfile>( | ||||||
|  |       `${environment.apiBaseUrl}${this.endpoint}/` | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update(profile: PaperlessUserProfile): Observable<PaperlessUserProfile> { | ||||||
|  |     return this.http.patch<PaperlessUserProfile>( | ||||||
|  |       `${environment.apiBaseUrl}${this.endpoint}/`, | ||||||
|  |       profile | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   generateAuthToken(): Observable<string> { | ||||||
|  |     return this.http.post<string>( | ||||||
|  |       `${environment.apiBaseUrl}${this.endpoint}/generate_auth_token/`, | ||||||
|  |       {} | ||||||
|  |     ) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										105
									
								
								src/documents/tests/test_api_profile.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										105
									
								
								src/documents/tests/test_api_profile.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,105 @@ | |||||||
|  | from django.contrib.auth.models import User | ||||||
|  | from rest_framework import status | ||||||
|  | from rest_framework.authtoken.models import Token | ||||||
|  | from rest_framework.test import APITestCase | ||||||
|  |  | ||||||
|  | from documents.tests.utils import DirectoriesMixin | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class TestApiProfile(DirectoriesMixin, APITestCase): | ||||||
|  |     ENDPOINT = "/api/profile/" | ||||||
|  |  | ||||||
|  |     def setUp(self): | ||||||
|  |         super().setUp() | ||||||
|  |  | ||||||
|  |         self.user = User.objects.create_superuser( | ||||||
|  |             username="temp_admin", | ||||||
|  |             first_name="firstname", | ||||||
|  |             last_name="surname", | ||||||
|  |         ) | ||||||
|  |         self.client.force_authenticate(user=self.user) | ||||||
|  |  | ||||||
|  |     def test_get_profile(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Configured user | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to get profile | ||||||
|  |         THEN: | ||||||
|  |             - Profile is returned | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         response = self.client.get(self.ENDPOINT) | ||||||
|  |  | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |  | ||||||
|  |         self.assertEqual(response.data["email"], self.user.email) | ||||||
|  |         self.assertEqual(response.data["first_name"], self.user.first_name) | ||||||
|  |         self.assertEqual(response.data["last_name"], self.user.last_name) | ||||||
|  |  | ||||||
|  |     def test_update_profile(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Configured user | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to update profile | ||||||
|  |         THEN: | ||||||
|  |             - Profile is updated | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         user_data = { | ||||||
|  |             "email": "new@email.com", | ||||||
|  |             "password": "superpassword1234", | ||||||
|  |             "first_name": "new first name", | ||||||
|  |             "last_name": "new last name", | ||||||
|  |         } | ||||||
|  |         response = self.client.patch(self.ENDPOINT, user_data) | ||||||
|  |  | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |  | ||||||
|  |         user = User.objects.get(username=self.user.username) | ||||||
|  |         self.assertTrue(user.check_password(user_data["password"])) | ||||||
|  |         self.assertEqual(user.email, user_data["email"]) | ||||||
|  |         self.assertEqual(user.first_name, user_data["first_name"]) | ||||||
|  |         self.assertEqual(user.last_name, user_data["last_name"]) | ||||||
|  |  | ||||||
|  |     def test_update_auth_token(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Configured user | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to generate auth token | ||||||
|  |         THEN: | ||||||
|  |             - Token is created the first time, updated the second | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.assertEqual(len(Token.objects.all()), 0) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{self.ENDPOINT}generate_auth_token/") | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |         token1 = Token.objects.filter(user=self.user).first() | ||||||
|  |         self.assertIsNotNone(token1) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{self.ENDPOINT}generate_auth_token/") | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |         token2 = Token.objects.filter(user=self.user).first() | ||||||
|  |  | ||||||
|  |         self.assertNotEqual(token1.key, token2.key) | ||||||
|  |  | ||||||
|  |     def test_profile_not_logged_in(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - User not logged in | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to get profile and update token | ||||||
|  |         THEN: | ||||||
|  |             - Profile is returned | ||||||
|  |         """ | ||||||
|  |  | ||||||
|  |         self.client.logout() | ||||||
|  |  | ||||||
|  |         response = self.client.get(self.ENDPOINT) | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) | ||||||
|  |  | ||||||
|  |         response = self.client.post(f"{self.ENDPOINT}generate_auth_token/") | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) | ||||||
| @@ -97,3 +97,19 @@ class GroupSerializer(serializers.ModelSerializer): | |||||||
|             "name", |             "name", | ||||||
|             "permissions", |             "permissions", | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ProfileSerializer(serializers.ModelSerializer): | ||||||
|  |     email = serializers.EmailField(allow_null=False) | ||||||
|  |     password = ObfuscatedUserPasswordField(required=False, allow_null=False) | ||||||
|  |     auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key") | ||||||
|  |  | ||||||
|  |     class Meta: | ||||||
|  |         model = User | ||||||
|  |         fields = ( | ||||||
|  |             "email", | ||||||
|  |             "password", | ||||||
|  |             "first_name", | ||||||
|  |             "last_name", | ||||||
|  |             "auth_token", | ||||||
|  |         ) | ||||||
|   | |||||||
| @@ -35,7 +35,9 @@ from documents.views import UiSettingsView | |||||||
| from documents.views import UnifiedSearchViewSet | from documents.views import UnifiedSearchViewSet | ||||||
| from paperless.consumers import StatusConsumer | from paperless.consumers import StatusConsumer | ||||||
| from paperless.views import FaviconView | from paperless.views import FaviconView | ||||||
|  | from paperless.views import GenerateAuthTokenView | ||||||
| from paperless.views import GroupViewSet | from paperless.views import GroupViewSet | ||||||
|  | from paperless.views import ProfileView | ||||||
| from paperless.views import UserViewSet | from paperless.views import UserViewSet | ||||||
| from paperless_mail.views import MailAccountTestView | from paperless_mail.views import MailAccountTestView | ||||||
| from paperless_mail.views import MailAccountViewSet | from paperless_mail.views import MailAccountViewSet | ||||||
| @@ -119,6 +121,12 @@ urlpatterns = [ | |||||||
|                     BulkEditObjectPermissionsView.as_view(), |                     BulkEditObjectPermissionsView.as_view(), | ||||||
|                     name="bulk_edit_object_permissions", |                     name="bulk_edit_object_permissions", | ||||||
|                 ), |                 ), | ||||||
|  |                 path("profile/generate_auth_token/", GenerateAuthTokenView.as_view()), | ||||||
|  |                 re_path( | ||||||
|  |                     "^profile/", | ||||||
|  |                     ProfileView.as_view(), | ||||||
|  |                     name="profile_view", | ||||||
|  |                 ), | ||||||
|                 *api_router.urls, |                 *api_router.urls, | ||||||
|             ], |             ], | ||||||
|         ), |         ), | ||||||
|   | |||||||
| @@ -7,7 +7,9 @@ from django.db.models.functions import Lower | |||||||
| from django.http import HttpResponse | from django.http import HttpResponse | ||||||
| from django.views.generic import View | from django.views.generic import View | ||||||
| from django_filters.rest_framework import DjangoFilterBackend | from django_filters.rest_framework import DjangoFilterBackend | ||||||
|  | from rest_framework.authtoken.models import Token | ||||||
| from rest_framework.filters import OrderingFilter | from rest_framework.filters import OrderingFilter | ||||||
|  | from rest_framework.generics import GenericAPIView | ||||||
| from rest_framework.pagination import PageNumberPagination | from rest_framework.pagination import PageNumberPagination | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| @@ -17,6 +19,7 @@ from documents.permissions import PaperlessObjectPermissions | |||||||
| from paperless.filters import GroupFilterSet | from paperless.filters import GroupFilterSet | ||||||
| from paperless.filters import UserFilterSet | from paperless.filters import UserFilterSet | ||||||
| from paperless.serialisers import GroupSerializer | from paperless.serialisers import GroupSerializer | ||||||
|  | from paperless.serialisers import ProfileSerializer | ||||||
| from paperless.serialisers import UserSerializer | from paperless.serialisers import UserSerializer | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -106,3 +109,54 @@ class GroupViewSet(ModelViewSet): | |||||||
|     filter_backends = (DjangoFilterBackend, OrderingFilter) |     filter_backends = (DjangoFilterBackend, OrderingFilter) | ||||||
|     filterset_class = GroupFilterSet |     filterset_class = GroupFilterSet | ||||||
|     ordering_fields = ("name",) |     ordering_fields = ("name",) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class ProfileView(GenericAPIView): | ||||||
|  |     """ | ||||||
|  |     User profile view, only available when logged in | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAuthenticated] | ||||||
|  |     serializer_class = ProfileSerializer | ||||||
|  |  | ||||||
|  |     def get(self, request, *args, **kwargs): | ||||||
|  |         user = self.request.user | ||||||
|  |  | ||||||
|  |         serializer = self.get_serializer(data=request.data) | ||||||
|  |         return Response(serializer.to_representation(user)) | ||||||
|  |  | ||||||
|  |     def patch(self, request, *args, **kwargs): | ||||||
|  |         serializer = self.get_serializer(data=request.data) | ||||||
|  |         serializer.is_valid(raise_exception=True) | ||||||
|  |         user = self.request.user if hasattr(self.request, "user") else None | ||||||
|  |  | ||||||
|  |         if len(serializer.validated_data.get("password").replace("*", "")) > 0: | ||||||
|  |             user.set_password(serializer.validated_data.get("password")) | ||||||
|  |             user.save() | ||||||
|  |         serializer.validated_data.pop("password") | ||||||
|  |  | ||||||
|  |         for key, value in serializer.validated_data.items(): | ||||||
|  |             setattr(user, key, value) | ||||||
|  |         user.save() | ||||||
|  |  | ||||||
|  |         return Response(serializer.to_representation(user)) | ||||||
|  |  | ||||||
|  |  | ||||||
|  | class GenerateAuthTokenView(GenericAPIView): | ||||||
|  |     """ | ||||||
|  |     Generates (or re-generates) an auth token, requires a logged in user | ||||||
|  |     unlike the default DRF endpoint | ||||||
|  |     """ | ||||||
|  |  | ||||||
|  |     permission_classes = [IsAuthenticated] | ||||||
|  |  | ||||||
|  |     def post(self, request, *args, **kwargs): | ||||||
|  |         user = self.request.user | ||||||
|  |  | ||||||
|  |         existing_token = Token.objects.filter(user=user).first() | ||||||
|  |         if existing_token is not None: | ||||||
|  |             existing_token.delete() | ||||||
|  |         token = Token.objects.create(user=user) | ||||||
|  |         return Response( | ||||||
|  |             token.key, | ||||||
|  |         ) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 shamoon
					shamoon