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/share_links/`: 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 | ||||
| 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 | ||||
|  | ||||
|     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. | ||||
|  | ||||
|     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> | ||||
|     ``` | ||||
|  | ||||
|     Tokens can be managed and revoked in the paperless admin. | ||||
|     Tokens can also be managed in the Django admin. | ||||
|  | ||||
| ## Searching for documents | ||||
|  | ||||
|   | ||||
| @@ -403,11 +403,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8838884664569764142" datatype="html"> | ||||
| @@ -496,15 +496,15 @@ | ||||
|         </context-group> | ||||
|         <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 context-type="linenumber">50</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1685061484835793745" datatype="html"> | ||||
| @@ -973,7 +973,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <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="linenumber">36</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">54</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> | ||||
|           <context context-type="linenumber">93</context> | ||||
| @@ -1396,7 +1400,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6839066544204061364" datatype="html"> | ||||
| @@ -1428,7 +1432,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="103921551219467537" datatype="html"> | ||||
| @@ -1630,11 +1634,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <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="linenumber">89</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/profile-edit-dialog/profile-edit-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">144</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2753185112875184719" datatype="html"> | ||||
|         <source>Saved user "<x id="PH" equiv-text="newUser.username"/>".</source> | ||||
| @@ -1961,37 +1969,44 @@ | ||||
|           <context context-type="linenumber">39</context> | ||||
|         </context-group> | ||||
|       </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"> | ||||
|         <source>Logout</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4895326106573044490" datatype="html"> | ||||
|         <source>Documentation</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6570363013146073520" datatype="html"> | ||||
|         <source>Dashboard</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/dashboard.component.html</context> | ||||
| @@ -2002,11 +2017,11 @@ | ||||
|         <source>Documents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context> | ||||
| @@ -2033,36 +2048,36 @@ | ||||
|         <source>Open documents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5687256342387781369" datatype="html"> | ||||
|         <source>Close all</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3897348120591552265" datatype="html"> | ||||
|         <source>Manage</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7437910965833684826" datatype="html"> | ||||
|         <source>Correspondents</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
| @@ -2073,11 +2088,11 @@ | ||||
|         <source>Tags</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/input/tags/tags.component.ts</context> | ||||
| @@ -2104,11 +2119,11 @@ | ||||
|         <source>Document Types</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
| @@ -2119,11 +2134,11 @@ | ||||
|         <source>Storage Paths</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/dashboard/widgets/statistics-widget/statistics-widget.component.html</context> | ||||
| @@ -2134,11 +2149,11 @@ | ||||
|         <source>Custom Fields</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5433675495457939071" datatype="html"> | ||||
|         <source>Templates</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1292737233370901804" datatype="html"> | ||||
|         <source>Mail</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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 purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7844706011418789951" datatype="html"> | ||||
|         <source>Administration</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <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> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1534029177398918729" datatype="html"> | ||||
|         <source>GitHub</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4112664765954374539" datatype="html"> | ||||
|         <source>is available.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1175891574282637937" datatype="html"> | ||||
|         <source>Click to view.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="9811291095862612" datatype="html"> | ||||
|         <source>Paperless-ngx can automatically check for updates</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="894819944961861800" datatype="html"> | ||||
|         <source> How does this work? </source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="509090351011426949" datatype="html"> | ||||
|         <source>Update available</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1542489069631984294" datatype="html"> | ||||
|         <source>Sidebar views updated</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">247</context> | ||||
|           <context context-type="linenumber">252</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3547923076537026828" datatype="html"> | ||||
|         <source>Error updating sidebar views</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">250</context> | ||||
|           <context context-type="linenumber">255</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2526035785704676448" datatype="html"> | ||||
|         <source>An error occurred while saving update checking settings.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/app-frame/app-frame.component.ts</context> | ||||
|           <context context-type="linenumber">271</context> | ||||
|           <context context-type="linenumber">276</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <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="linenumber">20</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">53</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/common/select-dialog/select-dialog.component.html</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="linenumber">12</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">18</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4249303448466017578" datatype="html"> | ||||
|         <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="linenumber">11</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">8</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5342432350421167093" datatype="html"> | ||||
|         <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="linenumber">13</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">28</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3586674587150281199" datatype="html"> | ||||
|         <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="linenumber">14</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">29</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8204176479746810612" datatype="html"> | ||||
|         <source>Active</source> | ||||
| @@ -3483,6 +3518,13 @@ | ||||
|           <context context-type="linenumber">155</context> | ||||
|         </context-group> | ||||
|       </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"> | ||||
|         <source>Edit Permissions</source> | ||||
|         <context-group purpose="location"> | ||||
| @@ -3637,6 +3679,109 @@ | ||||
|           <context context-type="linenumber">61</context> | ||||
|         </context-group> | ||||
|       </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"> | ||||
|         <source>Select</source> | ||||
|         <context-group purpose="location"> | ||||
| @@ -3677,13 +3822,6 @@ | ||||
|           <context context-type="linenumber">10,12</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/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"> | ||||
|         <source>Share</source> | ||||
|         <context-group purpose="location"> | ||||
| @@ -3691,13 +3829,6 @@ | ||||
|           <context context-type="linenumber">28</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/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"> | ||||
|         <source>Share archive version</source> | ||||
|         <context-group purpose="location"> | ||||
| @@ -3727,7 +3858,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8542568275115626925" datatype="html"> | ||||
| @@ -3762,21 +3893,21 @@ | ||||
|         <source><x id="PH" equiv-text="days"/> days</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2897042887615940599" datatype="html"> | ||||
|         <source>Error deleting link</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8400747326190565173" datatype="html"> | ||||
|         <source>Error creating link</source> | ||||
|         <context-group purpose="location"> | ||||
|           <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> | ||||
|       </trans-unit> | ||||
|       <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 { 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 { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component' | ||||
|  | ||||
| import localeAf from '@angular/common/locales/af' | ||||
| import localeAr from '@angular/common/locales/ar' | ||||
| @@ -256,6 +257,7 @@ function initializeApp(settings: SettingsService) { | ||||
|     CustomFieldsComponent, | ||||
|     CustomFieldEditDialogComponent, | ||||
|     CustomFieldsDropdownComponent, | ||||
|     ProfileEditDialogComponent, | ||||
|   ], | ||||
|   imports: [ | ||||
|     BrowserModule, | ||||
|   | ||||
| @@ -89,7 +89,7 @@ export class UsersAndGroupsComponent | ||||
|             $localize`Password has been changed, you will be logged out momentarily.` | ||||
|           ) | ||||
|           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) | ||||
|         } else { | ||||
|           this.toastService.showInfo( | ||||
|   | ||||
| @@ -39,6 +39,11 @@ | ||||
|           <p class="small mb-0 px-3 text-muted" i18n>Logged in as {{this.settingsService.displayName}}</p> | ||||
|           <div class="dropdown-divider"></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 }"> | ||||
|           <svg class="sidebaricon me-2" fill="currentColor"> | ||||
|             <use xlink:href="assets/bootstrap-icons.svg#gear"/> | ||||
|   | ||||
| @@ -9,7 +9,7 @@ import { | ||||
|   fakeAsync, | ||||
|   tick, | ||||
| } 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 { RouterTestingModule } from '@angular/router/testing' | ||||
| 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 { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop' | ||||
| import { PaperlessSavedView } from 'src/app/data/paperless-saved-view' | ||||
| import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||
|  | ||||
| const saved_views = [ | ||||
|   { | ||||
| @@ -86,6 +87,7 @@ describe('AppFrameComponent', () => { | ||||
|   let documentListViewService: DocumentListViewService | ||||
|   let router: Router | ||||
|   let savedViewSpy | ||||
|   let modalService: NgbModal | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     TestBed.configureTestingModule({ | ||||
| @@ -98,6 +100,7 @@ describe('AppFrameComponent', () => { | ||||
|         FormsModule, | ||||
|         ReactiveFormsModule, | ||||
|         DragDropModule, | ||||
|         NgbModalModule, | ||||
|       ], | ||||
|       providers: [ | ||||
|         SettingsService, | ||||
| @@ -120,6 +123,7 @@ describe('AppFrameComponent', () => { | ||||
|         ToastService, | ||||
|         OpenDocumentsService, | ||||
|         SearchService, | ||||
|         NgbModal, | ||||
|         { | ||||
|           provide: ActivatedRoute, | ||||
|           useValue: { | ||||
| @@ -148,6 +152,7 @@ describe('AppFrameComponent', () => { | ||||
|     openDocumentsService = TestBed.inject(OpenDocumentsService) | ||||
|     searchService = TestBed.inject(SearchService) | ||||
|     documentListViewService = TestBed.inject(DocumentListViewService) | ||||
|     modalService = TestBed.inject(NgbModal) | ||||
|     router = TestBed.inject(Router) | ||||
|  | ||||
|     jest | ||||
| @@ -363,4 +368,12 @@ describe('AppFrameComponent', () => { | ||||
|     >) | ||||
|     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, | ||||
|   moveItemInArray, | ||||
| } from '@angular/cdk/drag-drop' | ||||
| import { NgbModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-app-frame', | ||||
| @@ -69,6 +71,7 @@ export class AppFrameComponent | ||||
|     public settingsService: SettingsService, | ||||
|     public tasksService: TasksService, | ||||
|     private readonly toastService: ToastService, | ||||
|     private modalService: NgbModal, | ||||
|     permissionsService: PermissionsService | ||||
|   ) { | ||||
|     super() | ||||
| @@ -121,6 +124,13 @@ export class AppFrameComponent | ||||
|     this.isMenuCollapsed = true | ||||
|   } | ||||
|  | ||||
|   editProfile() { | ||||
|     this.modalService.open(ProfileEditDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|     }) | ||||
|     this.closeMenu() | ||||
|   } | ||||
|  | ||||
|   get openDocuments(): PaperlessDocument[] { | ||||
|     return this.openDocumentsService.getOpenDocuments() | ||||
|   } | ||||
|   | ||||
| @@ -1,8 +1,15 @@ | ||||
| <div class="mb-3"> | ||||
|   <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)"> | ||||
|   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
|   <div class="input-group" [class.is-invalid]="error"> | ||||
|     <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"> | ||||
|     {{error}} | ||||
|   </div> | ||||
|   <small *ngIf="hint" class="form-text text-muted" [innerHTML]="hint | safeHtml"></small> | ||||
| </div> | ||||
|   | ||||
| @@ -5,6 +5,7 @@ import { | ||||
|   NG_VALUE_ACCESSOR, | ||||
| } from '@angular/forms' | ||||
| import { PasswordComponent } from './password.component' | ||||
| import { By } from '@angular/platform-browser' | ||||
|  | ||||
| describe('PasswordComponent', () => { | ||||
|   let component: PasswordComponent | ||||
| @@ -33,4 +34,26 @@ describe('PasswordComponent', () => { | ||||
|     // fixture.detectChanges() | ||||
|     // 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 { AbstractInputComponent } from '../abstract-input' | ||||
|  | ||||
| @@ -15,7 +15,32 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   styleUrls: ['./password.component.scss'], | ||||
| }) | ||||
| export class PasswordComponent extends AbstractInputComponent<string> { | ||||
|   constructor() { | ||||
|     super() | ||||
|   @Input() | ||||
|   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> | ||||
|     </div> | ||||
|     <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> | ||||
|       <div class="invalid-feedback position-absolute top-100"> | ||||
|         {{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 { AbstractInputComponent } from '../abstract-input' | ||||
|  | ||||
| @@ -15,6 +15,9 @@ import { AbstractInputComponent } from '../abstract-input' | ||||
|   styleUrls: ['./text.component.scss'], | ||||
| }) | ||||
| export class TextComponent extends AbstractInputComponent<string> { | ||||
|   @Input() | ||||
|   autocomplete: string | ||||
|  | ||||
|   constructor() { | ||||
|     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", | ||||
|             "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 paperless.consumers import StatusConsumer | ||||
| from paperless.views import FaviconView | ||||
| from paperless.views import GenerateAuthTokenView | ||||
| from paperless.views import GroupViewSet | ||||
| from paperless.views import ProfileView | ||||
| from paperless.views import UserViewSet | ||||
| from paperless_mail.views import MailAccountTestView | ||||
| from paperless_mail.views import MailAccountViewSet | ||||
| @@ -119,6 +121,12 @@ urlpatterns = [ | ||||
|                     BulkEditObjectPermissionsView.as_view(), | ||||
|                     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, | ||||
|             ], | ||||
|         ), | ||||
|   | ||||
| @@ -7,7 +7,9 @@ from django.db.models.functions import Lower | ||||
| from django.http import HttpResponse | ||||
| from django.views.generic import View | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.authtoken.models import Token | ||||
| from rest_framework.filters import OrderingFilter | ||||
| from rest_framework.generics import GenericAPIView | ||||
| from rest_framework.pagination import PageNumberPagination | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| @@ -17,6 +19,7 @@ from documents.permissions import PaperlessObjectPermissions | ||||
| from paperless.filters import GroupFilterSet | ||||
| from paperless.filters import UserFilterSet | ||||
| from paperless.serialisers import GroupSerializer | ||||
| from paperless.serialisers import ProfileSerializer | ||||
| from paperless.serialisers import UserSerializer | ||||
|  | ||||
|  | ||||
| @@ -106,3 +109,54 @@ class GroupViewSet(ModelViewSet): | ||||
|     filter_backends = (DjangoFilterBackend, OrderingFilter) | ||||
|     filterset_class = GroupFilterSet | ||||
|     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