You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@trafficcontrol.apache.org by sh...@apache.org on 2022/08/04 14:05:56 UTC

[trafficcontrol] branch master updated: Obscure sensitive Traffic Portal form fields (#6981)

This is an automated email from the ASF dual-hosted git repository.

shamrick pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficcontrol.git


The following commit(s) were added to refs/heads/master by this push:
     new 2a87e2c707 Obscure sensitive Traffic Portal form fields (#6981)
2a87e2c707 is described below

commit 2a87e2c7079ac7598e622e66853cf201acc449db
Author: ocket8888 <oc...@apache.org>
AuthorDate: Thu Aug 4 08:05:51 2022 -0600

    Obscure sensitive Traffic Portal form fields (#6981)
    
    * Add some styling to be able to quickly re-use for obscurable text fields
    
    * Remove _ from servers controller
    
    * Make the ILO password field obscured by default
    
    * obscure DS SSL private keys by default
    
    * obscure remap text fields by default
    
    * Add CHANGELOG entry
    
    * Fix JSDoc required on both getter and setter of a single property
    
    * Enumerate the allowable values of an HTML "autocomplete" attribute
    
    * Add a reusable component that allows toggling revealing sensitive text
    
    * Switch login component to use new obscured text input
    
    * Switch server ILO password to use new obscurable text component
    
    * Obscure "Header Rewrite" Delivery Service fields by default
    
    * Update CHANGELOG
    
    * Fix missing module import in unit tests
    
    * Fix incorrect selectors in e2e tests
    
    * Remove tabindex putting focus on non-interactable element
---
 CHANGELOG.md                                       |   4 +-
 experimental/traffic-portal/.eslintrc.json         |   2 +-
 .../nightwatch/page_objects/login.ts               |   4 +-
 .../server-details/server-details.component.html   |   2 +-
 .../server-details.component.spec.ts               |   4 +-
 .../src/app/login/login.component.html             |   7 +-
 .../src/app/login/login.component.spec.ts          |  12 +-
 .../src/app/login/login.component.ts               |  41 ++-
 .../obscured-text-input.component.html             |  31 ++
 .../obscured-text-input.component.scss}            |  18 +-
 .../obscured-text-input.component.spec.ts          | 191 +++++++++++
 .../obscured-text-input.component.ts               | 371 +++++++++++++++++++++
 .../traffic-portal/src/app/shared/shared.module.ts |   7 +-
 experimental/traffic-portal/src/app/utils/index.ts | 267 +++++++++++++++
 .../app/src/common/modules/form/_form.scss         |  33 ++
 .../form.deliveryService.DNS.tpl.html              |  12 +-
 .../form.deliveryService.HTTP.tpl.html             |  12 +-
 .../form.deliveryService.anyMap.tpl.html           |   2 +-
 .../form.deliveryServiceSslKeys.tpl.html           |   2 +-
 .../modules/form/server/FormServerController.js    |  20 +-
 .../modules/form/server/form.server.tpl.html       |   7 +-
 21 files changed, 986 insertions(+), 63 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29699346da..8ba897454d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 
 ## [unreleased]
+### Changed
+- Traffic Portal now obscures sensitive text in Delivery Service "Raw Remap" fields, private SSL keys, "Header Rewrite" rules, and ILO interface passwords by default.
 
 ## [7.0.0] - 2022-07-19
 ### Added
@@ -13,7 +15,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).
 - Added a new Traffic Ops endpoint to `GET` capacity and telemetry data for CDNi integration.
 - Added SOA (Service Oriented Architecture) capability to CDN-In-A-Box.
 - Added a Traffic Ops endpoints to `PUT` a requested configuration change for a full configuration or per host and an endpoint to approve or deny the request.
-- Traffic Monitor config option `distributed_polling` which enables the ability for Traffic Monitor to poll a subset of the CDN and divide into "local peer groups" and "distributed peer groups". Traffic Monitors in the same group are local peers, while Traffic Monitors in other groups are distibuted peers. Each TM group polls the same set of cachegroups and gets availability data for the other cachegroups from other TM groups. This allows each TM to be responsible for polling a subset of [...]
+- Traffic Monitor config option `distributed_polling` which enables the ability for Traffic Monitor to poll a subset of the CDN and divide into "local peer groups" and "distributed peer groups". Traffic Monitors in the same group are local peers, while Traffic Monitors in other groups are distributed peers. Each TM group polls the same set of cachegroups and gets availability data for the other cachegroups from other TM groups. This allows each TM to be responsible for polling a subset o [...]
 - Added support for a new Traffic Ops GLOBAL profile parameter -- `tm_query_status_override` -- to override which status of Traffic Monitors to query (default: ONLINE).
 - Traffic Ops: added new `cdn.conf` option -- `user_cache_refresh_interval_sec` -- which enables an in-memory users cache to improve performance. Default: 0 (disabled).
 - Traffic Ops: added new `cdn.conf` option -- `server_update_status_cache_refresh_interval_sec` -- which enables an in-memory server update status cache to improve performance. Default: 0 (disabled).
diff --git a/experimental/traffic-portal/.eslintrc.json b/experimental/traffic-portal/.eslintrc.json
index 8ff5ffa0c1..65a76ad4f1 100644
--- a/experimental/traffic-portal/.eslintrc.json
+++ b/experimental/traffic-portal/.eslintrc.json
@@ -233,7 +233,7 @@
 						},
 						"checkConstructors": false,
 						"checkGetters": true,
-						"checkSetters": true,
+						"checkSetters": false,
 						"enableFixer": false
 					}
 				],
diff --git a/experimental/traffic-portal/nightwatch/page_objects/login.ts b/experimental/traffic-portal/nightwatch/page_objects/login.ts
index 69a83c8960..d14fb065de 100644
--- a/experimental/traffic-portal/nightwatch/page_objects/login.ts
+++ b/experimental/traffic-portal/nightwatch/page_objects/login.ts
@@ -64,13 +64,13 @@ const loginPageObject = {
 					selector: "button[name='login']"
 				},
 				passwordTxt: {
-					selector: "input#p"
+					selector: "input[name='p']"
 				},
 				resetBtn: {
 					selector: "button[name='reset']"
 				},
 				usernameTxt: {
-					selector: "input#u"
+					selector: "input[name='u']"
 				}
 			},
 			selector: "form[name='login']"
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
index fc4ebecc13..832c77a344 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.html
@@ -166,7 +166,7 @@ limitations under the License.
 					</mat-form-field>
 					<mat-form-field>
 						<mat-label><abbr title="Integrated Lights-Out Management">ILO</abbr> Password</mat-label>
-						<input matInput name="iloPassword" [(ngModel)]="server.iloPassword"/>
+						<tp-obscured-text-input name="iloPassword" [(value)]="server.iloPassword"></tp-obscured-text-input>
 					</mat-form-field>
 				</div>
 			</fieldset>
diff --git a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
index 1dd80876fa..1b8833e1b4 100644
--- a/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
+++ b/experimental/traffic-portal/src/app/core/servers/server-details/server-details.component.spec.ts
@@ -27,6 +27,7 @@ import { ServerService } from "src/app/api";
 import { APITestingModule } from "src/app/api/testing";
 import { defaultServer } from "src/app/models";
 import { CurrentUserService } from "src/app/shared/currentUser/current-user.service";
+import { SharedModule } from "src/app/shared/shared.module";
 
 import { ServerDetailsComponent } from "./server-details.component";
 
@@ -51,7 +52,8 @@ describe("ServerDetailsComponent", () => {
 				MatFormFieldModule,
 				MatInputModule,
 				BrowserAnimationsModule,
-				APITestingModule
+				APITestingModule,
+				SharedModule
 			],
 			providers: [
 				{provide: CurrentUserService, useValue: mockCurrentUserService},
diff --git a/experimental/traffic-portal/src/app/login/login.component.html b/experimental/traffic-portal/src/app/login/login.component.html
index 16ccf8f07e..3ade57fa60 100644
--- a/experimental/traffic-portal/src/app/login/login.component.html
+++ b/experimental/traffic-portal/src/app/login/login.component.html
@@ -20,14 +20,11 @@ limitations under the License.
 		<form name="login" (ngSubmit)="submitLogin()" ngNativeValidate>
 			<mat-form-field appearance="fill">
 				<mat-label>Username</mat-label>
-				<input matInput required autofocus type="text" [formControl]="u" id="u"/>
+				<input matInput required autofocus type="text" [(ngModel)]="u" name="u"/>
 			</mat-form-field>
 			<mat-form-field appearance="fill">
 				<mat-label>Password</mat-label>
-				<input matInput required [type]="hide ? 'password' : 'text'" [formControl]="p" id="p"/>
-				<button mat-icon-button type="button" matSuffix (click)="hide = !hide">
-					<mat-icon>{{hide ? "visibility_off" : "visibility"}}</mat-icon>
-				</button>
+				<tp-obscured-text-input [autocomplete]="passwordAutocomplete" required="true" [(value)]="p" name="p"></tp-obscured-text-input>
 			</mat-form-field>
 			<div>
 				<button name="login" mat-raised-button color="primary">Login</button>
diff --git a/experimental/traffic-portal/src/app/login/login.component.spec.ts b/experimental/traffic-portal/src/app/login/login.component.spec.ts
index 4cf38a1d75..107a570307 100644
--- a/experimental/traffic-portal/src/app/login/login.component.spec.ts
+++ b/experimental/traffic-portal/src/app/login/login.component.spec.ts
@@ -89,17 +89,17 @@ describe("LoginComponent", () => {
 		}
 	});
 
-	it("submits a login request", () => {
+	it("submits a login request", async () => {
 		expect(mockCurrentUserService.login).not.toHaveBeenCalled();
+		await expectAsync(component.submitLogin()).toBeRejected();
+		expect(mockCurrentUserService.login).not.toHaveBeenCalled();
+		component.u = "test-admin";
+		component.p = "twelve12!";
 		component.submitLogin();
 		expect(mockCurrentUserService.login).toHaveBeenCalled();
-		component.u.setValue("test-admin");
-		component.p.setValue("twelve12!");
+		component.u = "server error";
 		component.submitLogin();
 		expect(mockCurrentUserService.login).toHaveBeenCalledTimes(2);
-		component.u.setValue("server error");
-		component.submitLogin();
-		expect(mockCurrentUserService.login).toHaveBeenCalledTimes(3);
 	});
 
 	it("opens the password reset dialog", () => {
diff --git a/experimental/traffic-portal/src/app/login/login.component.ts b/experimental/traffic-portal/src/app/login/login.component.ts
index fec50888c3..873e61e4b2 100644
--- a/experimental/traffic-portal/src/app/login/login.component.ts
+++ b/experimental/traffic-portal/src/app/login/login.component.ts
@@ -12,13 +12,14 @@
 * limitations under the License.
 */
 import { Component, OnInit } from "@angular/core";
-import { UntypedFormControl } from "@angular/forms";
 import { MatDialog } from "@angular/material/dialog";
 import { Router, ActivatedRoute } from "@angular/router";
 
 import { CurrentUserService } from "src/app/shared/currentUser/current-user.service";
 import {TpHeaderService} from "src/app/shared/tp-header/tp-header.service";
 
+import { AutocompleteValue } from "../utils";
+
 import { ResetPasswordDialogComponent } from "./reset-password-dialog/reset-password-dialog.component";
 
 /**
@@ -36,10 +37,13 @@ export class LoginComponent implements OnInit {
 	/** Controls if the password is shown in plain text */
 	public hide = true;
 
+	/** The password field's autocomplete value. */
+	public readonly passwordAutocomplete = AutocompleteValue.CURRENT_PASSWORD;
+
 	/** The user-entered username. */
-	public u = new UntypedFormControl("");
+	public u = "";
 	/** The user-entered password. */
-	public p = new UntypedFormControl("");
+	public p: string | null = "";
 
 	constructor(
 		private readonly route: ActivatedRoute,
@@ -74,22 +78,25 @@ export class LoginComponent implements OnInit {
 	}
 
 	/**
-	 * Handles submission of the Login form, and redirects the user back to their requested page
-	 * should it be succesful. If the user had not yet requested a page, they will be redirected to
-	 * `/`
+	 * Handles submission of the Login form, and redirects the user back to
+	 * their requested page should it be successful. If the user had not yet
+	 * requested a page, they will be redirected to `/`
 	 */
-	public submitLogin(): void {
-		this.auth.login(this.u.value, this.p.value).then(
-			response => {
-				if (response) {
-					this.headerSvc.headerHidden.next(false);
-					this.router.navigate([this.returnURL]);
-				}
-			},
-			err => {
-				console.error("login failed:", err);
+	public async submitLogin(): Promise<void> {
+		if (!this.p) {
+			// This shouldn't really be possible, since the value will only be
+			// `null` if the control is invalid.
+			throw new Error("password is required");
+		}
+		try {
+			const response = await this.auth.login(this.u, this.p);
+			if (response) {
+				this.headerSvc.headerHidden.next(false);
+				this.router.navigate([this.returnURL]);
 			}
-		);
+		} catch (err) {
+			console.error("login failed:", err);
+		}
 	}
 
 	/** Opens the "reset password" dialog. */
diff --git a/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.html b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.html
new file mode 100644
index 0000000000..00eccd70a2
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.html
@@ -0,0 +1,31 @@
+<!--
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<div class="mat-select-trigger" (focusin)="focus()" (focusout)="blur($event)">
+<input
+	matInput
+	[type]="type"
+	[formControl]="control"
+	[placeholder]="placeholder"
+	[required]="required"
+	[disabled]="disabled"
+	[attr.aria-describedby]="describedBy"
+	[attr.aria-labelledby]="parentFormField?.getLabelId()"
+	[name]="name"
+	(change)="onChange($event)"
+	[attr.autocomplete]="autocomplete"
+/>
+<button type="button" mat-icon-button (click)="toggle()">
+	<mat-icon>{{ type === "password" ? "visibility" : "visibility_off"}}</mat-icon>
+</button>
+</div>
diff --git a/experimental/traffic-portal/src/app/utils/index.ts b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.scss
similarity index 77%
copy from experimental/traffic-portal/src/app/utils/index.ts
copy to experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.scss
index 9005f9a19f..7382393741 100644
--- a/experimental/traffic-portal/src/app/utils/index.ts
+++ b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.scss
@@ -11,8 +11,18 @@
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
+div {
+	display: inline-flex;
+	width: 100%;
+	align-items: center;
 
-export * from "./order-by";
-export * from "./fuzzy";
-export * from "./ip";
-export * from "./time";
+	button {
+		margin-bottom: 1em;
+		flex: 0;
+		min-width: 40px;
+	}
+
+	input {
+		flex: 1;
+	}
+}
diff --git a/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.spec.ts b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.spec.ts
new file mode 100644
index 0000000000..a66aa0c8c4
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.spec.ts
@@ -0,0 +1,191 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { ComponentFixture, TestBed } from "@angular/core/testing";
+
+import { AutocompleteValue } from "src/app/utils";
+
+import { ObscuredTextInputComponent } from "./obscured-text-input.component";
+
+const name = "property";
+
+describe("ObscuredTextInputComponent", () => {
+	let component: ObscuredTextInputComponent;
+	let fixture: ComponentFixture<ObscuredTextInputComponent>;
+
+	beforeEach(async () => {
+		await TestBed.configureTestingModule({
+			declarations: [ ObscuredTextInputComponent ]
+		})
+			.compileComponents();
+
+		fixture = TestBed.createComponent(ObscuredTextInputComponent);
+		component = fixture.componentInstance;
+		component.name = name;
+		fixture.detectChanges();
+	});
+
+	it("should create", () => {
+		expect(component).toBeTruthy();
+		expect(component.name).toBe(name);
+	});
+
+	it("gets the error state correctly", () => {
+		expect(component.errorState).toBeFalse();
+		component.control.setErrors({customError: true});
+		expect(component.errorState).toBeFalse();
+		component.touched = true;
+		expect(component.errorState).toBeTrue();
+		component.control.setErrors(null);
+		expect(component.errorState).toBeFalse();
+	});
+
+	it("gets and sets the value", () => {
+		expect(component.value).toBe("");
+		component.value = null as string | null;
+		expect(component.value).toBe("");
+		const value = "foo";
+		component.value = value;
+		expect(component.value).toBe(value);
+		component.control.setErrors({customError: true});
+		expect(component.value).toBeNull();
+	});
+
+	it("emits state changes", () => {
+		const spyName = "subscriber";
+		const spy = jasmine.createSpy(spyName);
+		const subscription = component.stateChanges.subscribe(spy);
+		expect(spy).not.toHaveBeenCalled();
+		component.value = "foo";
+		expect(spy).toHaveBeenCalled();
+		component.autocomplete = AutocompleteValue.ON;
+		expect(spy).toHaveBeenCalledTimes(2);
+		component.placeholder = "placeholder";
+		expect(spy).toHaveBeenCalledTimes(3);
+		component.disabled = true;
+		expect(spy).toHaveBeenCalledTimes(4);
+		component.required = true;
+		expect(spy).toHaveBeenCalledTimes(5);
+		component.maxLength = 10;
+		expect(spy).toHaveBeenCalledTimes(6);
+		component.minLength = 5;
+		expect(spy).toHaveBeenCalledTimes(7);
+		component.focused = true;
+		expect(spy).toHaveBeenCalledTimes(8);
+		component.focus();
+		expect(spy).toHaveBeenCalledTimes(8);
+		component.blur(new FocusEvent("blur"));
+		expect(spy).toHaveBeenCalledTimes(8);
+		if (!(component.elementRef.nativeElement instanceof HTMLElement)) {
+			return fail("element ref not a reference to an element");
+		}
+		component.blur(new FocusEvent("blur", {relatedTarget: component.elementRef.nativeElement.parentElement}));
+		expect(spy).toHaveBeenCalledTimes(9);
+		component.focus();
+		expect(spy).toHaveBeenCalledTimes(10);
+		component.writeValue("testquest");
+		expect(spy).toHaveBeenCalledTimes(11);
+		component.onChange(new Event("change"));
+		expect(spy).toHaveBeenCalledTimes(12);
+		component.setDisabledState(false);
+		expect(spy).toHaveBeenCalledTimes(13);
+		subscription.unsubscribe();
+		component.ngOnDestroy();
+		expect(component.stateChanges.isStopped).toBeTrue();
+	});
+
+	it("gets and sets its inputs with the right default values", () => {
+		// Defaults
+		expect(component.minLength).toBe(-1);
+		expect(component.maxLength).toBe(-1);
+		expect(component.touched).toBeFalse();
+		expect(component.autocomplete).toBe(AutocompleteValue.OFF);
+		expect(component.userDescribedBy).toBe("");
+		expect(component.required).toBeFalse();
+		expect(component.placeholder).toBe("");
+
+		// Changes
+		component.minLength = 5;
+		expect(component.minLength).toBe(5);
+		component.maxLength = 5;
+		expect(component.maxLength).toBe(5);
+		component.touched = true;
+		expect(component.touched).toBe(true);
+		component.autocomplete = AutocompleteValue.ON;
+		expect(component.autocomplete).toBe(AutocompleteValue.ON);
+		component.userDescribedBy = "element-id another-element-id";
+		expect(component.userDescribedBy).toBe("element-id another-element-id");
+		component.required = true;
+		expect(component.required).toBeTrue();
+		component.placeholder = "placeholder";
+		expect(component.placeholder).toBe("placeholder");
+	});
+
+	it("sets describedby from Angular forms", () => {
+		expect(component.describedBy).toBe("");
+		component.setDescribedByIds(["element-id", "another-element-id"]);
+		expect(component.describedBy).toBe("element-id another-element-id");
+	});
+
+	it("registers onChange callbacks", () => {
+		if (!(component.elementRef.nativeElement instanceof HTMLElement)) {
+			return fail("element ref not set to a reference");
+		}
+		const spy = jasmine.createSpy("changeSpy");
+		component.registerOnChange(spy);
+		expect(spy).not.toHaveBeenCalled();
+		const input = component.elementRef.nativeElement.querySelector("input");
+		input?.dispatchEvent(new Event("change"));
+		expect(spy).toHaveBeenCalled();
+	});
+
+	it("registers onTouch callbacks", () => {
+		if (!(component.elementRef.nativeElement instanceof HTMLElement)) {
+			return fail("element ref not set to a reference");
+		}
+		const spy = jasmine.createSpy("touchSpy");
+		component.registerOnTouched(spy);
+		expect(spy).not.toHaveBeenCalled();
+		component.blur(new FocusEvent("blur", {relatedTarget: component.elementRef.nativeElement.parentElement}));
+		expect(spy).toHaveBeenCalled();
+	});
+
+	it("toggles state", () => {
+		expect(component.type).toBe("password");
+		component.toggle();
+		expect(component.type).toBe("text");
+		component.toggle();
+		expect(component.type).toBe("password");
+	});
+
+	it("focuses the input element on container clicks", () => {
+		if (!(component.elementRef.nativeElement instanceof HTMLElement)) {
+			return fail("element ref not set to a reference");
+		}
+		const input = component.elementRef.nativeElement.querySelector("input");
+		if (!input) {
+			return fail("component doesn't contain an input element");
+		}
+		const button = component.elementRef.nativeElement.querySelector("button");
+		if (!button) {
+			return fail("component doesn't contain a button element");
+		}
+		expect(document.activeElement).not.toBe(input);
+		component.onContainerClick({target: button} as unknown as MouseEvent);
+		expect(document.activeElement).not.toBe(input);
+		component.onContainerClick({target: input} as unknown as MouseEvent);
+		expect(document.activeElement).not.toBe(input);
+		component.onContainerClick({target: component.elementRef.nativeElement} as unknown as MouseEvent);
+		expect(document.activeElement).toBe(input);
+	});
+});
diff --git a/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.ts b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.ts
new file mode 100644
index 0000000000..2200ebd42e
--- /dev/null
+++ b/experimental/traffic-portal/src/app/shared/obscured-text-input/obscured-text-input.component.ts
@@ -0,0 +1,371 @@
+/*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*     http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+import { FocusMonitor } from "@angular/cdk/a11y";
+import { BooleanInput, coerceBooleanProperty } from "@angular/cdk/coercion";
+import { Component, ElementRef, HostBinding, Input, type OnDestroy, Optional, Self, Output, EventEmitter } from "@angular/core";
+import { type ControlValueAccessor, NgControl, FormControl } from "@angular/forms";
+import { MatFormField, MatFormFieldControl } from "@angular/material/form-field";
+import { Subject } from "rxjs";
+
+import { AutocompleteValue } from "src/app/utils";
+
+/**
+ * An ObscuredTextInputComponent implements a form control compatible with
+ * mat-form-field (but not `matSuffix`!) that hides its data as a "password"
+ * field by default, but provides a toggle-able "reveal" button that allows
+ * displaying it in plain text. Note that this form control MUST have a name,
+ * regardless of context.
+ *
+ * This can be bound to a form control or you can do a 2-way binding to the
+ * `value` property.
+ *
+ * @example <caption>Using with a controller's FormControl property</caption>
+ * <tp-obscured-text-input [name]="password" [formControl]="passwordControl">
+ * </tp-obscured-text-input>
+ * @example <caption>Using with a controller's FormGroup property</caption>
+ * <tp-obscured-text-input [name]="password" [formControlName]="password">
+ * </tp-obscured-text-input>
+ * @example <caption>Binding directly to value</caption>
+ * <tp-obscured-text-input [name]="password" [(value)]="password">
+ * </tp-obscured-text-input>
+ */
+@Component({
+	providers: [{provide: MatFormFieldControl, useExisting: ObscuredTextInputComponent}],
+	selector: "tp-obscured-text-input[name]",
+	styleUrls: ["./obscured-text-input.component.scss"],
+	templateUrl: "./obscured-text-input.component.html"
+})
+export class ObscuredTextInputComponent implements OnDestroy, MatFormFieldControl<string>, ControlValueAccessor {
+
+	/** The next unique ID for an `ObscuredTextInputComponent` instance. */
+	public static nextID = 0;
+
+	/** autocomplete */
+	private autoc = AutocompleteValue.OFF;
+	/** placeholder */
+	private phd = "";
+	/** focused */
+	private foc = false;
+	/** required */
+	private req = false;
+	/** disabled */
+	private dis = false;
+	/** maxlength */
+	private max = -1;
+	/** minlength */
+	private min = -1;
+	/** register-able trigger that fires on model changes*/
+	private onChanged: ((_: unknown) => void) | null = null;
+	/** register-able trigger that fires when the control is "touched" */
+	private onTouched: (() => void) | null = null;
+
+	/**
+	 * The type of the input field of the control, which can be toggled between
+	 * an "obscured" `"password"` state (default) and a "revealed" `"text"`
+	 * state.
+	 */
+	public type: "text" | "password" = "password";
+	/**
+	 * A subject that emits (nothing) whenever the state of the form control
+	 * changes.
+	 */
+	public stateChanges = new Subject<void>();
+	/**
+	 * The controller for the underlying `<input>` element.
+	 */
+	public readonly control = new FormControl<string>("");
+	/** Tracks whether the user has "touched" the control. */
+	public touched = false;
+	/** A name Angular uses to track unique form control types. */
+	public readonly controlType = "tp-obscured-text-input";
+
+	/** `true` if there is no value entered, `false` otherwise. */
+	public get empty(): boolean {
+		return !this.value;
+	}
+
+	/** `true` if the control is invalid, `false` otherwise. */
+	public get errorState(): boolean {
+		return this.touched && this.control.invalid;
+	}
+
+	/**
+	 * `true` if the Angular Material floating label should be in its floating
+	 * state, `false` if it should instead be placed inside the control.
+	 */
+	@HostBinding("class.floating")
+	public get shouldLabelFloat(): boolean {
+		return !this.empty || this.focused;
+	}
+
+	/** A unique ID for the form control. */
+	@HostBinding()
+	public id = `tp-obscured-text-input-${ObscuredTextInputComponent.nextID++}`;
+
+	// This is necessary to avoid clashing with consumer-set aria-describedby
+	// IDs.
+	// eslint-disable-next-line @angular-eslint/no-input-rename
+	@Input("aria-describedby")
+	public userDescribedBy = "";
+
+	/** The form control's `aria-describedby` attribute value. */
+	public describedBy = "";
+
+	/** A name for the form control. */
+	@Input() public name!: string;
+
+	/** An autocomplete setting for the form control. */
+	@Input()
+	public get autocomplete(): AutocompleteValue {
+		return this.autoc;
+	}
+	public set autocomplete(a: AutocompleteValue) {
+		this.autoc = a;
+		this.stateChanges.next();
+	}
+
+	/**
+	 * The value of the form control. If the control is invalid, the value will
+	 * be `null`.
+	 */
+	@Input()
+	public get value(): string | null {
+		if (this.control.valid) {
+			return this.control.value;
+		}
+		return null;
+	}
+	public set value(v: string | null) {
+		this.control.setValue(v ?? "");
+		this.valueChange.emit(v);
+		this.stateChanges.next();
+	}
+
+	/**
+	 * Emits the value whenever it changes.
+	 */
+	@Output() public valueChange = new EventEmitter<string | null>();
+
+	/**
+	 * Placeholder text for the form control.
+	 */
+	@Input()
+	public get placeholder(): string {
+		return this.phd;
+	}
+	public set placeholder(p: string) {
+		this.phd = p;
+		this.stateChanges.next();
+	}
+
+	/**
+	 * Whether the form control should be invalid when empty.
+	 */
+	@Input()
+	public get required(): boolean {
+		return this.req;
+	}
+	public set required(r: BooleanInput) {
+		this.req = coerceBooleanProperty(r);
+		this.stateChanges.next();
+	}
+
+	/**
+	 * Whether the form control should be disabled.
+	 */
+	@Input()
+	public get disabled(): boolean {
+		return this.dis;
+	}
+	public set disabled(d: BooleanInput) {
+		this.dis = coerceBooleanProperty(d);
+		this.stateChanges.next();
+	}
+
+	/**
+	 * A maximum allowed length.
+	 */
+	@Input()
+	public get maxLength(): number {
+		return this.max;
+	}
+	public set maxLength(n: number) {
+		this.max = n;
+		this.stateChanges.next();
+	}
+
+	/**
+	 * A minimum required length.
+	 */
+	@Input()
+	public get minLength(): number {
+		return this.min;
+	}
+	public set minLength(n: number) {
+		this.min = n;
+		this.stateChanges.next();
+	}
+
+	/**
+	 * Whether the form control currently has user focus.
+	 */
+	public get focused(): boolean {
+		return this.foc;
+	}
+	public set focused(f: boolean) {
+		this.foc = f;
+		this.stateChanges.next();
+	}
+
+	constructor(
+		private readonly focusMonitor: FocusMonitor,
+		@Optional() @Self() public ngControl: NgControl,
+		@Optional() public parentFormField: MatFormField,
+		public readonly elementRef: ElementRef,
+	) {
+		if (this.ngControl !== null) {
+			this.ngControl.valueAccessor = this;
+		}
+	}
+
+	/**
+	 * Angular lifecycle hook; cleans up persistent resources.
+	 */
+	public ngOnDestroy(): void {
+		this.stateChanges.complete();
+		this.focusMonitor.stopMonitoring(this.elementRef);
+	}
+
+	/**
+	 * Toggles the obscured/revealed state of the form control's text.
+	 */
+	public toggle(): void {
+		if (this.type === "password") {
+			this.type = "text";
+		} else {
+			this.type = "password";
+		}
+	}
+
+	/**
+	 * Event handler for events where the form control gains focus.
+	 */
+	public focus(): void {
+		if (!this.focused) {
+			this.focused = true;
+		}
+	}
+
+	/**
+	 * Event handler for events where the form control loses focus.
+	 *
+	 * @param e The focus event in question.
+	 */
+	public blur(e: FocusEvent): void {
+		const ref = this.elementRef.nativeElement;
+		if ((e.relatedTarget !== null && !ref.contains(e.relatedTarget)) || ref.contains(e.target)) {
+			this.focused = false;
+			if (!this.touched) {
+				this.touched = true;
+				if (this.onTouched) {
+					this.onTouched();
+				}
+			}
+		}
+	}
+
+	/**
+	 * Adds extra `aria-describedby` labels to the form control.
+	 *
+	 * @param ids Any added `aria-describedby` labels added by a Reactive form
+	 * group.
+	 */
+	public setDescribedByIds(ids: string[]): void {
+		this.describedBy = ids.join(" ");
+	}
+
+	/**
+	 * Handles a user clicking on the component's container/host element,
+	 * whenever that click can't be directly associated with some element that
+	 * it contains.
+	 *
+	 * @param event The click event in question.
+	 */
+	public onContainerClick(event: MouseEvent): void {
+		if (event.target instanceof HTMLElement) {
+			const tag = event.target.tagName.toLowerCase();
+			switch(tag) {
+				case "button":
+				case "input":
+					break;
+				default:
+					this.elementRef.nativeElement.querySelector("input").focus();
+			}
+		}
+	}
+
+	/**
+	 * Used by Angular to set the control's value when used in a Form Group.
+	 *
+	 * @param obj The value being set.
+	 */
+	public writeValue(obj: string): void {
+		this.value = obj;
+	}
+
+	/**
+	 * Handles changes to the control.
+	 *
+	 * @param e The input change event.
+	 */
+	public onChange(e: Event): void {
+		this.valueChange.emit(this.value);
+		if (this.onChanged && e.target instanceof HTMLInputElement) {
+			this.onChanged(e.target.value);
+		}
+		this.stateChanges.next();
+	}
+
+	/**
+	 * Registers a callback that will be called with the control's new value
+	 * whenever that value changes.
+	 *
+	 * @param fn A function that will be called when the control's value
+	 * changes.
+	 */
+	public registerOnChange(fn: (_: unknown) => void): void {
+		this.onChanged = fn;
+	}
+
+	/**
+	 * Registers a callback that will be called whenever the user "touches" the
+	 * control.
+	 *
+	 * @param fn A function that will be called when the control is touched.
+	 */
+	public registerOnTouched(fn: () => void): void {
+		this.onTouched = fn;
+	}
+
+	/**
+	 * Used by Angular to set whether the form control is disabled when it is
+	 * used in a Reactive Form group.
+	 *
+	 * @param disabled `true` if the control should be disabled, `false`
+	 * otherwise.
+	 */
+	public setDisabledState(disabled: boolean): void {
+		this.disabled = disabled;
+	}
+}
diff --git a/experimental/traffic-portal/src/app/shared/shared.module.ts b/experimental/traffic-portal/src/app/shared/shared.module.ts
index 81bf704fbf..3da8ed1891 100644
--- a/experimental/traffic-portal/src/app/shared/shared.module.ts
+++ b/experimental/traffic-portal/src/app/shared/shared.module.ts
@@ -27,6 +27,7 @@ import { GenericTableComponent } from "./generic-table/generic-table.component";
 import { AlertInterceptor } from "./interceptor/alerts.interceptor";
 import { ErrorInterceptor } from "./interceptor/error.interceptor";
 import { LoadingComponent } from "./loading/loading.component";
+import { ObscuredTextInputComponent } from "./obscured-text-input/obscured-text-input.component";
 import { BooleanFilterComponent } from "./table-components/boolean-filter/boolean-filter.component";
 import { EmailCellRendererComponent } from "./table-components/email-cell-renderer/email-cell-renderer.component";
 import { SSHCellRendererComponent } from "./table-components/ssh-cell-renderer/ssh-cell-renderer.component";
@@ -50,7 +51,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 		LinechartDirective,
 		SSHCellRendererComponent,
 		EmailCellRendererComponent,
-		TelephoneCellRendererComponent
+		TelephoneCellRendererComponent,
+		ObscuredTextInputComponent
 	],
 	exports: [
 		AlertComponent,
@@ -60,7 +62,8 @@ import { CustomvalidityDirective } from "./validation/customvalidity.directive";
 		BooleanFilterComponent,
 		UpdateCellRendererComponent,
 		CustomvalidityDirective,
-		LinechartDirective
+		LinechartDirective,
+		ObscuredTextInputComponent,
 	],
 	imports: [
 		AppUIModule,
diff --git a/experimental/traffic-portal/src/app/utils/index.ts b/experimental/traffic-portal/src/app/utils/index.ts
index 9005f9a19f..3efe9a047c 100644
--- a/experimental/traffic-portal/src/app/utils/index.ts
+++ b/experimental/traffic-portal/src/app/utils/index.ts
@@ -16,3 +16,270 @@ export * from "./order-by";
 export * from "./fuzzy";
 export * from "./ip";
 export * from "./time";
+
+/**
+ * These are the values that may be given to the `autocomplete` attribute of an
+ * HTML form control.
+ */
+export const enum AutocompleteValue {
+	// Disable/enable with no guidance.
+	/**
+	 * The browser is not permitted to automatically enter or select a value for
+	 * this field. It is possible that the document or application provides its
+	 * own autocomplete feature, or that security concerns require that the
+	 * field's value not be automatically entered.
+	 *
+	 * Note that in most modern browsers, setting autocomplete to "off" will not
+	 * prevent a password manager from asking the user if they would like to
+	 * save username and password information, or from automatically filling in
+	 * those values in a site's login form.
+	 */
+	OFF = "off",
+	/**
+	 * The browser is allowed to automatically complete the input. No guidance
+	 * is provided as to the type of data expected in the field, so the browser
+	 * may use its own judgement.
+	 */
+	ON = "on",
+
+	// Misc.
+	/** A preferred language, given as a valid BCP 47 language tag. */
+	LANGUAGE = "language",
+	/**
+	 * The URL of an image representing the person, company, or contact
+	 * information given in the other fields in the form.
+	 */
+	PHOTO = "photo",
+	/**
+	 * A URL, such as a home page or company web site address as appropriate
+	 * given the context of the other fields in the form.
+	 */
+	URL = "url",
+
+	// Physical addresses
+	/**
+	 * A street address. This can be multiple lines of text, and should fully
+	 * identify the location of the address within its second administrative
+	 * level (typically a city or town), but should not include the city name,
+	 * ZIP or postal code, or country name.
+	 */
+	STREET_ADDRESS = "street-address",
+	/**
+	 * The first line of the street address. This should only be present if the
+	 * {@link AutocompleteValue.STREET_ADDRESS} is not present.
+	 */
+	ADDRESS_LINE_1 = "address-line-1",
+	/**
+	 * The second line of the street address. This should only be present if the
+	 * {@link AutocompleteValue.STREET_ADDRESS} is not present.
+	 */
+	ADDRESS_LINE_2 = "address-line-2",
+	/**
+	 * The third line of the street address. This should only be present if the
+	 * {@link AutocompleteValue.STREET_ADDRESS} is not present.
+	 */
+	ADDRESS_LINE_3 = "address-line-3",
+	/**
+	 * The first administrative level in the address. This is typically the
+	 * province in which the address is located. In the United States, this
+	 * would be the state. In Switzerland, the canton. In the United Kingdom,
+	 * the post town.
+	 */
+	ADDRESS_LEVEL_1 = "address-level-1",
+	/**
+	 * The second administrative level, in addresses with at least two of them.
+	 * In countries with two administrative levels, this would typically be the
+	 * city, town, village, or other locality in which the address is located.
+	 */
+	ADDRESS_LEVEL_2 = "address-level-2",
+	/**
+	 * The third administrative level, in addresses with at least three
+	 * administrative levels.
+	 */
+	ADDRESS_LEVEL_3 = "address-level-3",
+	/**
+	 * The finest-grained administrative level, in addresses which have four
+	 * levels.
+	 */
+	ADDRESS_LEVEL_4 = "address-level-4",
+	/** A country or territory code. */
+	COUNTRY = "country",
+	/** A country or territory name. */
+	COUNTRY_NAME = "country-name",
+	/** A postal code (in the United States, this is the ZIP code). */
+	POSTAL_CODE = "postal code",
+
+	// Credit card information
+	/**
+	 * The full name as printed on or associated with a payment instrument such
+	 * as a credit card. Using a full name field is preferred, typically, over
+	 * breaking the name into pieces.
+	 */
+	CREDIT_CARD_NAME = "cc-name",
+	/**
+	 * A given (first) name as given on a payment instrument like a credit
+	 * card.
+	 */
+	CREDIT_CARD_GIVEN_NAME = "cc-given-name",
+	/** A middle name as given on a payment instrument or credit card. */
+	CREDIT_CARD_ADDITIONAL_NAME = "cc-additional-name",
+	/** A family name, as given on a credit card. */
+	CREDIT_CARD_FAMILY_NAME = "cc-family-name",
+	/**
+	 * A credit card number or other number identifying a payment method, such
+	 * as an account number.
+	 */
+	CREDIT_CARD_NUMBER = "cc-number",
+	/**
+	 * A payment method expiration date, typically in the form "MM/YY" or
+	 * "MM/YYYY".
+	 */
+	CREDIT_CARD_EXP = "cc-exp",
+	/** The month in which the payment method expires. */
+	CREDIT_CARD_EXP_MONTH = "cc-exp-month",
+	/** The year in which the payment method expires. */
+	CREDIT_CARD_EXP_YEAR = "cc-exp-year",
+	/**
+	 * The security code for the payment instrument; on credit cards, this is
+	 * the 3-digit verification number on the back of the card.
+	 */
+	CREDIT_CARD_CSC = "cc-csc",
+	/** The type of payment instrument (such as "Visa" or "Master Card"). */
+	CREDIT_CARD_TYPE = "cc-type",
+
+	// Contact information
+	/**
+	 * A full telephone number, including the country code. If you need to break
+	 * the phone number up into its components, you can use
+	 * {@link AutocompleteValue.TELEPHONE_COUNTRY_CODE},
+	 * {@link AutocompleteValue.TELEPHONE_NATIONAL},
+	 * {@link AutocompleteValue.TELEPHONE_AREA_CODE},
+	 * {@link AutocompleteValue.TELEPHONE_LOCAL}, and/or
+	 * {@link AutocompleteValue.TELEPHONE_EXTENSION} for those fields.
+	 */
+	TELEPHONE = "tel",
+	/**
+	 * The country code, such as "1" for the United States, Canada, and other
+	 * areas in North America and parts of the Caribbean.
+	 */
+	TELEPHONE_COUNTRY_CODE = "tel-country-code",
+	/**
+	 * The entire phone number without the country code component, including a
+	 * country-internal prefix. For the phone number "1-855-555-6502", this
+	 * field's value would be "855-555-6502".
+	 */
+	TELEPHONE_NATIONAL = "tel-national",
+	/**
+	 * The area code, with any country-internal prefix applied if appropriate.
+	 */
+	TELEPHONE_AREA_CODE = "tel-area-code",
+	/**
+	 * The phone number without the country or area code. This can be split
+	 * further into two parts, for phone numbers which have an exchange number
+	 * and then a number within the exchange. For the phone number "555-6502",
+	 * use {@link AutocompleteValue.TELEPHONE_LOCAL_PREFIX} for "555" and
+	 * {@link AutocompleteValue.TELEPHONE_LOCAL_SUFFIX} for "6502".
+	 */
+	TELEPHONE_LOCAL = "tel-local",
+	/**
+	 * The exchange number part of a telephone number without a country or area
+	 * code.
+	 */
+	TELEPHONE_LOCAL_PREFIX = "tel-local-prefix",
+	/**
+	 * A number within an exchange for a telephone number without a country or
+	 * area code.
+	 */
+	TELEPHONE_LOCAL_SUFFIX = "tel-local-suffix",
+	/**
+	 * A telephone extension code within the phone number, such as a room or
+	 * suite number in a hotel or an office extension in a company.
+	 */
+	TELEPHONE_EXTENSION = "tel-extension",
+	/** A URL for an instant messaging protocol endpoint, such as "xmpp:username@example.net". */
+	IMPP = "impp",
+	/** An email address */
+	EMAIL = "email",
+
+	// Identification/authentication
+	/** A username or account name. */
+	USERNAME = "username",
+	/**
+	 * A new password. When creating a new account or changing passwords, this
+	 * should be used for an "Enter your new password" or "Confirm new password"
+	 * field, as opposed to a general "Enter your current password" field that
+	 * might be present. This may be used by the browser both to avoid
+	 * accidentally filling in an existing password and to offer assistance in
+	 * creating a secure password
+	 */
+	NEW_PASSWORD = "new-password",
+	/** The user's current password. */
+	CURRENT_PASSWORD = "current-password",
+	/** A one-time code used for verifying user identity. */
+	ONE_TIME_CODE = "one-time-code",
+	/**
+	 * A job title, or the title a person has within an organization, such as
+	 * "Senior Technical Writer", "President", or "Assistant Troop Leader".
+	 */
+	ORGANIZATION_TITLE = "organization-title",
+	/**
+	 * A company or organization name, such as "Acme Widget Company" or "Girl
+	 * Scouts of America".
+	 */
+	ORGANIZATION = "organization",
+	/** A birth date, as a full date. */
+	BIRTHDAY = "bday",
+	/** The day of the month of a birth date. */
+	BIRTHDAY_DAY = "bday-day",
+	/** The month of the year of a birth date. */
+	BIRTHDAY_MONTH = "bday-month",
+	/** The year of a birth date. */
+	BIRTHDAY_YEAR = "bday-year",
+	/**
+	 * A gender identity (such as "Female", "Fa'afafine", "Male"), as freeform
+	 * text without newlines.
+	 */
+	SEX = "sex",
+	/**
+	 * A gender identity (such as "Female", "Fa'afafine", "Male"), as freeform
+	 * text without newlines.
+	 *
+	 * Alias of "sex", because the field is actually meant to more generally
+	 * express gender than biological sex.
+	 */
+	GENDER = "sex",
+	/**
+	 * The field expects the value to be a person's full name. Using "name"
+	 * rather than breaking the name down into its components is generally
+	 * preferred because it avoids dealing with the wide diversity of human
+	 * names and how they are structured; however, you can use the following
+	 * autocomplete values if you do need to break the name down into its
+	 * components:
+	 * - {@link AutocompleteValue.HONORIFIC_PREFIX}
+	 * - {@link AutocompleteValue.GIVEN_NAME}
+	 * - {@link AutocompleteValue.ADDITIONAL_NAME}
+	 * - {@link AutocompleteValue.FAMILY_NAME}
+	 * - {@link AutocompleteValue.HONORIFIC_SUFFIX}
+	 * - {@link AutocompleteValue.NICKNAME}
+	 */
+	NAME = "name",
+
+	// Typically, `NAME` should be used instead of any of these, to avoid having
+	// to deal with the vast variety of human naming customs. Nevertheless, they
+	// are valid `autocomplete` attribute values.
+	/**
+	 * The prefix or title, such as "Mrs.", "Mr.", "Miss", "Ms.", "Dr.", or
+	 * "Mlle.".
+	 */
+	HONORIFIC_PREFIX = "honorific-prefix",
+	/** The given (or "first") name. */
+	GIVEN_NAME = "given-name",
+	/** The middle name. */
+	ADDITIONAL_NAME = "additional-name",
+	/** The family (or "last") name. */
+	FAMILY_NAME = "family-name",
+	/** The suffix, such as "Jr.", "B.Sc.", "PhD.", "MBASW", or "IV". */
+	HONORIFIC_SUFFIX = "honorific-suffix",
+	/** A nickname or handle. */
+	NICKNAME = "nickname",
+}
diff --git a/traffic_portal/app/src/common/modules/form/_form.scss b/traffic_portal/app/src/common/modules/form/_form.scss
index a8e69b1c98..c3cb8a10c1 100644
--- a/traffic_portal/app/src/common/modules/form/_form.scss
+++ b/traffic_portal/app/src/common/modules/form/_form.scss
@@ -337,3 +337,36 @@ input:focus + .slider {
 input:checked + .slider::before {
   transform: translateX(13px);
 }
+
+.revealable-password-container {
+	display: flex;
+	justify-content: space-between;
+	padding: 0;
+	align-content: center;
+
+	> input {
+		appearance: none;
+		border: none;
+		flex: 1;
+		padding: 6px 12px;
+		color: inherit;
+		background: transparent;
+		line-height: inherit;
+		height: 100%;
+	}
+
+	> button {
+		width: 34px;
+		border: none;
+	}
+}
+
+.private-text {
+	color: transparent;
+	text-shadow: 0 0 8px rgba(0,0,0,0.5);
+
+	&:active, &:hover {
+		color: inherit;
+		text-shadow: none;
+	}
+}
diff --git a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.DNS.tpl.html b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.DNS.tpl.html
index 1f4bacad5c..9efa68c673 100644
--- a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.DNS.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.DNS.tpl.html
@@ -625,7 +625,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="edgeHeaderRewrite" name="edgeHeaderRewrite" class="form-control" ng-model="deliveryService.edgeHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="edgeHeaderRewrite" name="edgeHeaderRewrite" class="form-control private-text" ng-model="deliveryService.edgeHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.edgeHeaderRewrite, 'maxlength')">Only applicable with non topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.edgeHeaderRewrite != dsCurrent.edgeHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -650,7 +650,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="midHeaderRewrite" name="midHeaderRewrite" class="form-control" ng-model="deliveryService.midHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="midHeaderRewrite" name="midHeaderRewrite" class="form-control private-text" ng-model="deliveryService.midHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.midHeaderRewrite, 'maxlength')">Only applicable with non topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.midHeaderRewrite != dsCurrent.midHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -675,7 +675,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="firstHeaderRewrite" name="firstHeaderRewrite" class="form-control" ng-model="deliveryService.firstHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="firstHeaderRewrite" name="firstHeaderRewrite" class="form-control private-text" ng-model="deliveryService.firstHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.firstHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.firstHeaderRewrite != dsCurrent.firstHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -700,7 +700,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="innerHeaderRewrite" name="innerHeaderRewrite" class="form-control" ng-model="deliveryService.innerHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="innerHeaderRewrite" name="innerHeaderRewrite" class="form-control private-text" ng-model="deliveryService.innerHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.innerHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.innerHeaderRewrite != dsCurrent.innerHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -725,7 +725,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="lastHeaderRewrite" name="lastHeaderRewrite" class="form-control" ng-model="deliveryService.lastHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="lastHeaderRewrite" name="lastHeaderRewrite" class="form-control private-text ng-model="deliveryService.lastHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.lastHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.lastHeaderRewrite != dsCurrent.lastHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -764,7 +764,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="remapText" name="remapText" class="form-control" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3"></textarea>
+                            <textarea id="remapText" name="remapText" class="form-control private-text" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.remapText, 'pattern')">No Line Breaks</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.remapText != dsCurrent.remapText">
                                 <h3 ng-if="open()">Current Value</h3>
diff --git a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.HTTP.tpl.html b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.HTTP.tpl.html
index 5888df3660..f2248c02c2 100644
--- a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.HTTP.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.HTTP.tpl.html
@@ -625,7 +625,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="edgeHeaderRewrite" name="edgeHeaderRewrite" class="form-control" ng-model="deliveryService.edgeHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="edgeHeaderRewrite" name="edgeHeaderRewrite" class="form-control private-text ng-model="deliveryService.edgeHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.edgeHeaderRewrite, 'maxlength')">Only applicable with non topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.edgeHeaderRewrite != dsCurrent.edgeHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -650,7 +650,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="midHeaderRewrite" name="midHeaderRewrite" class="form-control" ng-model="deliveryService.midHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="midHeaderRewrite" name="midHeaderRewrite" class="form-control private-text ng-model="deliveryService.midHeaderRewrite" ng-maxlength="(deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.midHeaderRewrite, 'maxlength')">Only applicable with non topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.midHeaderRewrite != dsCurrent.midHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -675,7 +675,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="firstHeaderRewrite" name="firstHeaderRewrite" class="form-control" ng-model="deliveryService.firstHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="firstHeaderRewrite" name="firstHeaderRewrite" class="form-control private-text ng-model="deliveryService.firstHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.firstHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.firstHeaderRewrite != dsCurrent.firstHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -700,7 +700,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="innerHeaderRewrite" name="innerHeaderRewrite" class="form-control" ng-model="deliveryService.innerHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="innerHeaderRewrite" name="innerHeaderRewrite" class="form-control private-text ng-model="deliveryService.innerHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.innerHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.innerHeaderRewrite != dsCurrent.innerHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -725,7 +725,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="lastHeaderRewrite" name="lastHeaderRewrite" class="form-control" ng-model="deliveryService.lastHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
+                            <textarea id="lastHeaderRewrite" name="lastHeaderRewrite" class="form-control private-text ng-model="deliveryService.lastHeaderRewrite" ng-maxlength="(!deliveryService.topology) ? 0 : ''" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.lastHeaderRewrite, 'maxlength')">Only applicable with topology-based delivery services</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.lastHeaderRewrite != dsCurrent.lastHeaderRewrite">
                                 <h3 ng-if="open()">Current Value</h3>
@@ -764,7 +764,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="remapText" name="remapText" class="form-control" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3"></textarea>
+                            <textarea id="remapText" name="remapText" class="form-control private-text" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3"></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.remapText, 'pattern')">No Line Breaks</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.remapText != dsCurrent.remapText">
                                 <h3 ng-if="open()">Current Value</h3>
diff --git a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.anyMap.tpl.html b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.anyMap.tpl.html
index 9f85a27476..2614ea17ae 100644
--- a/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.anyMap.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/deliveryService/form.deliveryService.anyMap.tpl.html
@@ -364,7 +364,7 @@ under the License.
                         </div>
                         </label>
                         <div class="col-md-10 col-sm-10 col-xs-12">
-                            <textarea id="remapText" name="remapText" class="form-control" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3" required></textarea>
+                            <textarea id="remapText" name="remapText" class="form-control private-text" ng-model="deliveryService.remapText" ng-pattern="/^[^\n\r]*$/" rows="3" required></textarea>
                             <small class="input-error" ng-show="hasPropertyError(cacheConfig.remapText, 'pattern')">No Line Breaks</small>
                             <aside class="current-value" ng-if="settings.isRequest" ng-show="deliveryService.remapText != dsCurrent.remapText">
                                 <h3 ng-if="open()">Current Value</h3>
diff --git a/traffic_portal/app/src/common/modules/form/deliveryServiceSslKeys/form.deliveryServiceSslKeys.tpl.html b/traffic_portal/app/src/common/modules/form/deliveryServiceSslKeys/form.deliveryServiceSslKeys.tpl.html
index f1185c0346..053489d19c 100644
--- a/traffic_portal/app/src/common/modules/form/deliveryServiceSslKeys/form.deliveryServiceSslKeys.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/deliveryServiceSslKeys/form.deliveryServiceSslKeys.tpl.html
@@ -78,7 +78,7 @@ under the License.
             <div class="form-group" ng-class="{'has-error': hasError(dsSslKeyForm.privateKey), 'has-feedback': hasError(dsSslKeyForm.privateKey)}">
                 <label for="privateKey" class="control-label col-md-2 col-sm-2 col-xs-12">Private Key *</label>
                 <div class="col-md-10 col-sm-10 col-xs-12">
-                    <textarea id="privateKey" name="privateKey" type="text" class="form-control" ng-model="sslKeys.certificate.key" rows="25" required></textarea>
+                    <textarea id="privateKey" name="privateKey" type="text" class="form-control private-text" ng-model="sslKeys.certificate.key" rows="25" required></textarea>
                     <small class="input-error" ng-show="hasPropertyError(dsSslKeyForm.privateKey, 'required')">Required</small>
                     <span ng-show="hasError(dsSslKeyForm.privateKey)" class="form-control-feedback"><i class="fa fa-times"></i></span>
                 </div>
diff --git a/traffic_portal/app/src/common/modules/form/server/FormServerController.js b/traffic_portal/app/src/common/modules/form/server/FormServerController.js
index ec36dcd801..b52b39706b 100644
--- a/traffic_portal/app/src/common/modules/form/server/FormServerController.js
+++ b/traffic_portal/app/src/common/modules/form/server/FormServerController.js
@@ -52,13 +52,10 @@ var FormServerController = function(server, $scope, $location, $state, $uibModal
             });
     };
 
-    var getProfiles = function(cdnId) {
-        profileService.getProfiles({ orderby: 'name', cdn: cdnId })
-            .then(function(result) {
-                $scope.profiles = _.filter(result, function(profile) {
-                    return profile.type != 'DS_PROFILE'; // DS profiles are not intended for servers
-                });
-            });
+	/** @param {number} cdnId */
+    async function getProfiles(cdnId) {
+        const result = await profileService.getProfiles({ orderby: "name", cdn: cdnId })
+		$scope.profiles = result.filter(profile => profile.type !== "DS_PROFILE"); // DS profiles are not intended for servers
     };
 
     $scope.getProfileID = function(profileName) {
@@ -79,6 +76,15 @@ var FormServerController = function(server, $scope, $location, $state, $uibModal
         }
     }
 
+	$scope.iloInputType = "password";
+	$scope.toggleILO = () => {
+		if ($scope.iloInputType === "password") {
+			$scope.iloInputType = "text";
+		} else {
+			$scope.iloInputType = "password";
+		}
+	};
+
     $scope.deleteProfile = function(index) {
         $scope.serverForm.$setDirty();
         $scope.server.profileNames.splice(index, 1);
diff --git a/traffic_portal/app/src/common/modules/form/server/form.server.tpl.html b/traffic_portal/app/src/common/modules/form/server/form.server.tpl.html
index 90bfdfa61b..5b985899a3 100644
--- a/traffic_portal/app/src/common/modules/form/server/form.server.tpl.html
+++ b/traffic_portal/app/src/common/modules/form/server/form.server.tpl.html
@@ -222,7 +222,7 @@ under the License.
 
             <label ng-class="{'has-error': hasError(serverForm.iloUsername)}">ILO Username</label>
             <div ng-class="{'has-error': hasError(serverForm.iloUsername), 'has-feedback': hasError(serverForm.iloUsername)}">
-                <input name="iloUsername" type="text" class="form-control" ng-model="server.iloUsername" maxlength="45" pattern="\S*"/>
+                <input name="iloUsername" type="text" class="form-control" ng-model="server.iloUsername" maxlength="45" pattern="\S*" autocomplete="off"/>
                 <small class="input-error" ng-show="hasPropertyError(serverForm.iloUsername, 'maxlength')">Too Long</small>
                 <small class="input-error" ng-show="hasPropertyError(serverForm.iloUsername, 'pattern')">No Spaces</small>
                 <span ng-show="hasError(serverForm.iloUsername)" class="form-control-feedback"><i class="fa fa-times"></i></span>
@@ -230,7 +230,10 @@ under the License.
 
             <label ng-class="{'has-error': hasError(serverForm.iloPassword)}">ILO Password</label>
             <div ng-class="{'has-error': hasError(serverForm.iloPassword), 'has-feedback': hasError(serverForm.iloPassword)}">
-                <input name="iloPassword" type="text" class="form-control" ng-model="server.iloPassword" maxlength="45"/>
+				<div class="form-control revealable-password-container">
+					<input name="iloPassword" type="{{iloInputType}}" class="form-control" ng-model="server.iloPassword" autocomplete="off" maxlength="45"/>
+					<button type="button" title="toggle revealed password" aria-label="toggle revealed password" ng-click="toggleILO()"><i class="fa fa-eye"></i></button>
+				</div>
                 <small class="input-error" ng-show="hasPropertyError(serverForm.iloPassword, 'maxlength')">Too Long</small>
                 <span ng-show="hasError(serverForm.iloPassword)" class="form-control-feedback"><i class="fa fa-times"></i></span>
             </div>