Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 4 additions & 6 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,6 @@ against this app's route files. Update this table as pages move between columns.

### Not yet migrated

- [ ] **Personal two-factor setup** (`/profile/two_factor_profile`, `two_factor_profile_controller`)
— user's own 2FA device enrollment. (Org-level two-factor *policy* settings are already
migrated to `organizations/settings/two-factor`; this is the separate personal setup flow.)
- [ ] **Salesforce login callback** (`/salesforce_login`, `salesforce_login_controller`) — the
OAuth success/failure landing page. (Distinct from Salesforce org *settings*, which are
already migrated to `organizations/settings/salesforce`.)
Expand All @@ -123,8 +120,8 @@ to look for whole features hiding *inside* an already-migrated page rather than
route. Notes from that pass:

- **No additional missing pages found.** Everything reachable from a controller maps to either an
already-migrated page (`goal_service`→`data-quality/goal`, `two_factor_service`→personal 2FA
above, `compliance_metric_service`→`insights/config/program-config.component.ts`,
already-migrated page (`goal_service`→`data-quality/goal`, `two_factor_service`→
`profile/two-factor`, `compliance_metric_service`→`insights/config/program-config.component.ts`,
`map_service`'s EEEJ/disadvantaged-tract filter→`inventory-list/map/map.component.ts`,
`property_measure_service`→`inventory-detail/detail` scenarios grid, `pairing_service`→Pairing
workflow, `facilities_plan_service`/`facilities_plan_run_service`/`service_service`/
Expand Down Expand Up @@ -155,7 +152,8 @@ route. Notes from that pass:
### Already migrated (for reference — don't re-port these)

Everything else in `seed.js`'s state table has a corresponding route in this app, including:
`home`→dashboard, `profile`/`security`/`developer`/`admin`→`profile/*`, `analyses`/`analysis`/
`home`→dashboard, `profile`/`security`/`developer`/`admin`→`profile/*`,
`two_factor_profile`→`profile/two-factor`, `analyses`/`analysis`/
`analysis_run`→`analyses/*`, `mapping`/`dataset_list`/`dataset_detail`→`datasets/*`,
`pairing`→`datasets/pairing/:id/:type`, `about`/
`contact`/`api_docs`, the full `organization_*` settings family (settings, access-level-tree,
Expand Down
15 changes: 15 additions & 0 deletions public/i18n/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"A Custom Report allows you to look at aggregated data across your inventories and cycles. This is useful to see how specific metrics change over time.": "A Custom Report allows you to look at aggregated data across your inventories and cycles. This is useful to see how specific metrics change over time.",
"A list of your imported headers.": "A list of your imported headers.",
"A preview of your concatenated data will appear in the box below.": "A preview of your concatenated data will appear in the box below.",
"A sample token has been sent to {{email}}": "A sample token has been sent to {{email}}",
"ABOUT_SEED": "The <strong>Standard Energy Efficiency Data (SEED)™ Platform<\/strong> is a software application that helps organizations easily manage data on the energy performance of large groups of buildings. Users can combine data from multiple sources, clean and validate it, and share the information with others. The software application provides an easy, flexible, and cost-effective method to improve the quality and availability of data to help demonstrate the economic and environmental benefits of energy efficiency, to implement programs, and to target investment activity.",
"ACCESS_LEVEL_DELETE_AREYOUSURE": "Are you sure you want to delete this Access Level Instance? The operation will also delete all Access Level Instances below this one.",
"ACCESS_LEVEL_DESCRIPTION": " Define access levels to structure your organization's content. An organization can have one or more access levels. All organizations have at least one level (level 1), which is named with the organization name by default. All access level names are customizable.",
Expand Down Expand Up @@ -171,6 +172,8 @@
"Audit Template Upload Results": "Audit Template Upload Results",
"Auditing": "Auditing",
"Auditor Recommended Measures": "Auditor Recommended Measures",
"Authenticator App Verified!": "Authenticator App Verified!",
"Authenticator QR Code": "Authenticator QR Code",
"Auto Matching": "Auto Matching",
"Auto-Populate Sample Data": "Auto-Populate Sample Data",
"Available BuildingSync Measures": "Available BuildingSync Measures",
Expand Down Expand Up @@ -256,6 +259,7 @@
"Change Your Password": "Change Your Password",
"Change my password": "Change my password",
"Changed By": "Changed By",
"Changes Saved": "Changes Saved",
"Chart Legend": "Chart Legend",
"Chart Options": "Chart Options",
"Chiller": "Chiller",
Expand Down Expand Up @@ -504,6 +508,7 @@
"Developer": "Developer",
"Development Team": "Development Team",
"Diesel": "Diesel",
"Disabled": "Disabled",
"Dismiss": "Dismiss",
"Display Columns": "Display Columns",
"Display Fields": "Display Fields",
Expand Down Expand Up @@ -608,6 +613,7 @@
"Enter first name": "Enter first name",
"Enter last name": "Enter last name",
"Enter sub-organization name": "Enter sub-organization name",
"Enter the 6-digit code from your authenticator app": "Enter the 6-digit code from your authenticator app",
"Enter the minimum square footage for report period.": "Enter the minimum square footage for report period.",
"Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.": "Enter the minimum threshold count of buildings that can be returned in a shared query. The building count threshold is important for allowing other organizations to perform statistical analysis on your data without revealing information about individual buildings.",
"Enter your Salesforce OAuth credentials to connect portfolio data": "Enter your Salesforce OAuth credentials to connect portfolio data",
Expand Down Expand Up @@ -808,6 +814,7 @@
"INVALID_XML_ZIP_EXTENSION_ALERT": "<strong>Sorry!<\/strong> SEED doesn't currently support that file format. Only <strong>.xml<\/strong> and <strong>.zip<\/strong> files are supported.",
"IRREVERSIBLE_OPERATION_WARNING": "This operation is irreversible.",
"ITEMS_WILL_NOT_CHANGE": "these will not change",
"If you are unable to scan the QR code, select a different method below.": "If you are unable to scan the QR code, select a different method below.",
"Ignored duplicates of existing properties": "Ignored duplicates of existing properties",
"Ignored duplicates of existing tax lots": "Ignored duplicates of existing tax lots",
"Ignored property duplicates within the import file": "Ignored property duplicates within the import file",
Expand All @@ -820,6 +827,7 @@
"Import directly from ESPM": "Import directly from ESPM",
"Import from Audit Template": "Import from Audit Template",
"Import setting:": "Import setting:",
"Important": "Important",
"Imported": "Imported",
"In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.": "In addition, you need to specify where the field should be associated with Tax Lot data or Property data. This will affect how the data is matched and merged, as well as how it is displayed in the Inventory view.",
"Inactive": "Inactive",
Expand Down Expand Up @@ -1223,6 +1231,7 @@
"RENAME_SYS_LEVEL_COL": "Rename allows users to change the name of the underlying field shown in Column Name \/ Field Name. Renaming a field means that field will be changed in all the data for the organization.",
"REVIEW YOUR DATA MAPPINGS": "REVIEW YOUR DATA MAPPINGS",
"REVIEW_MATCHING_CRITERIA": "Review your matching criteria on the Column Settings page before importing data for the first time. You will not be able to remove fields from the matching criteria after data is added to your organization.",
"Re-Generate Token": "Re-Generate Token",
"Read more here.": "Read more here.",
"Reading": "Mesure",
"Really reset all passwords? This will sign you out of SEED.": "Really reset all passwords? This will sign you out of SEED.",
Expand Down Expand Up @@ -1264,6 +1273,7 @@
"Require In Plan Column": "Require In Plan Column",
"Require two-factor authentication": "Require two-factor authentication",
"Required": "Required",
"Resend Test Email Token": "Resend Test Email Token",
"Reset": "Reset",
"Reset All Rules": "Reset All Rules",
"Reset Defaults": "Reset Defaults",
Expand Down Expand Up @@ -1395,6 +1405,7 @@
"Save Settings": "Save Settings",
"Saving access levels": "Saving access levels",
"Saving…": "Saving…",
"Scan the QR code below using your authenticator app of choice (ex: Google Authenticator, Microsoft Authenticator, Authy, etc...)": "Scan the QR code below using your authenticator app of choice (ex: Google Authenticator, Microsoft Authenticator, Authy, etc...)",
Comment thread
Copilot marked this conversation as resolved.
Outdated
"Schedule Weekly Update": "Schedule Weekly Update",
"Scheduled Daily Update": "Scheduled Daily Update",
"Scope of properties included in this plan run": "Scope of properties included in this plan run",
Expand All @@ -1412,6 +1423,7 @@
"Select None": "Select None",
"Select Properties to Update": "Select Properties to Update",
"Select a Custom Report to get started!": "Select a Custom Report to get started!",
"Select a Different Method": "Select a Different Method",
"Select a Goal Type. A Standard goal will calculate a standard EUI per sqft. A Transaction goal will also calculate an EUI per transaction.": "Select a Goal Type. A Standard goal will calculate a standard EUI per sqft. A Transaction goal will also calculate an EUI per transaction.",
"Select a Program to get started!": "Select a Program to get started!",
"Select a column to show data on this axis. Only columns with one of these data types will be listed:": "Select a column to show data on this axis. Only columns with one of these data types will be listed:",
Expand Down Expand Up @@ -1631,6 +1643,7 @@
"UPLOAD_SENSOR_READINGS_BUTTON": "Upload Sensor Readings",
"URL_OPTIONS": "URL Options",
"USING_DEFAULT_UNITS_WARNING": "For columns with unit settings, default units will be used for conversions.",
"Unable to verify code. Please try again.": "Unable to verify code. Please try again.",
"Unexpected Portfolio Summary Calculations?": "Unexpected Portfolio Summary Calculations?",
"Uniformat Category": "Uniformat Category",
"Unique name shown in the dropdown selector": "Unique name shown in the dropdown selector",
Expand Down Expand Up @@ -1692,6 +1705,8 @@
"Valid": "Valid",
"Valid email required": "Valid email required",
"Value": "Value",
"Verification Code": "Verification Code",
"Verify": "Verify",
"Version": "Version",
"View Compliance Tracking": "View Compliance Tracking",
"View Project": "View Project",
Expand Down
1 change: 1 addition & 0 deletions src/@seed/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export * from './salesforce'
export * from './scenario'
export * from './sensor'
export * from './swagger'
export * from './two-factor'
export * from './ubid'
export * from './user'
export * from './version'
2 changes: 2 additions & 0 deletions src/@seed/api/two-factor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './two-factor.service'
export * from './two-factor.types'
64 changes: 64 additions & 0 deletions src/@seed/api/two-factor/two-factor.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { HttpErrorResponse } from '@angular/common/http'
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import type { Observable } from 'rxjs'
import { catchError } from 'rxjs'
import { ErrorService } from '@seed/services'
import type {
GenerateTwoFactorQrCodeResponse,
ResendTwoFactorTokenEmailResponse,
SetTwoFactorMethodResponse,
TwoFactorMethods,
VerifyTwoFactorCodeResponse,
} from './two-factor.types'

@Injectable({ providedIn: 'root' })
export class TwoFactorService {
private _httpClient = inject(HttpClient)
private _errorService = inject(ErrorService)

/**
* Sets the current user's two-factor method. When enabling the token method for the first
* time, the response includes a `qr_code` to be verified before the change takes effect.
*/
setMethod(orgId: number, userEmail: string, methods: TwoFactorMethods): Observable<SetTwoFactorMethodResponse> {
const url = '/api/v3/two_factor/set_method/'
const params = { organization_id: orgId }
return this._httpClient.post<SetTwoFactorMethodResponse>(url, { user_email: userEmail, methods }, { params }).pipe(
catchError((error: HttpErrorResponse) => this._errorService.handleError(error, 'Error updating two-factor method')),
)
}

/**
* Sends a new two-factor email token to the user (email method only).
*/
resendTokenEmail(orgId: number, userEmail: string): Observable<ResendTwoFactorTokenEmailResponse> {
const url = '/api/v3/two_factor/resend_token_email/'
const params = { organization_id: orgId }
return this._httpClient.post<ResendTwoFactorTokenEmailResponse>(url, { user_email: userEmail }, { params }).pipe(
catchError((error: HttpErrorResponse) => this._errorService.handleError(error, 'Error resending token email')),
)
}

/**
* Generates a new QR code to be scanned by an authenticator app before verifying the token method.
*/
generateQrCode(orgId: number, userEmail: string): Observable<GenerateTwoFactorQrCodeResponse> {
const url = '/api/v3/two_factor/generate_qr_code/'
const params = { organization_id: orgId }
return this._httpClient.post<GenerateTwoFactorQrCodeResponse>(url, { user_email: userEmail }, { params }).pipe(
catchError((error: HttpErrorResponse) => this._errorService.handleError(error, 'Error generating QR code')),
)
}

/**
* Verifies a 6-digit authenticator app code against the session's pending QR code secret.
*/
verifyCode(orgId: number, userEmail: string, code: string): Observable<VerifyTwoFactorCodeResponse> {
const url = '/api/v3/two_factor/verify_code/'
const params = { organization_id: orgId }
return this._httpClient.post<VerifyTwoFactorCodeResponse>(url, { user_email: userEmail, code }, { params }).pipe(
catchError((error: HttpErrorResponse) => this._errorService.handleError(error, 'Error verifying code')),
)
}
}
27 changes: 27 additions & 0 deletions src/@seed/api/two-factor/two-factor.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export type TwoFactorMethod = 'disabled' | 'email' | 'token'

export type TwoFactorMethods = {
disabled: boolean;
email: boolean;
token: boolean;
}

export type SetTwoFactorMethodResponse = {
status?: string;
message?: string;
methods?: TwoFactorMethods;
qr_code?: string;
}

export type ResendTwoFactorTokenEmailResponse = {
message: string;
}

export type GenerateTwoFactorQrCodeResponse = {
qr_code: string;
}

export type VerifyTwoFactorCodeResponse = {
success?: boolean;
error?: string;
}
2 changes: 2 additions & 0 deletions src/@seed/materials/material.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { MatMenuModule } from '@angular/material/menu'
import { MatPaginatorModule } from '@angular/material/paginator'
import { MatProgressBarModule } from '@angular/material/progress-bar'
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'
import { MatRadioModule } from '@angular/material/radio'
import { MatSelectModule, MatSelectTrigger } from '@angular/material/select'
import { MatSidenavModule } from '@angular/material/sidenav'
import { MatSlideToggleModule } from '@angular/material/slide-toggle'
Expand Down Expand Up @@ -45,6 +46,7 @@ export const MaterialImports = [
MatProgressBarModule,
MatProgressSpinnerModule,
MatOptionModule,
MatRadioModule,
MatSelectModule,
MatStepperModule,
MatSelectTrigger,
Expand Down
9 changes: 8 additions & 1 deletion src/app/modules/profile/profile.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SharedImports } from '@seed/directives'
imports: [HorizontalNavigationComponent, SharedImports, RouterOutlet],
})
export class ProfileComponent {
tabs = ['Profile Info', 'Security', 'Developer', 'Admin']
tabs = ['Profile Info', 'Security', 'Two Factor Profile', 'Developer', 'Admin']

readonly navigation: NavigationItem[] = [
{
Expand All @@ -28,6 +28,13 @@ export class ProfileComponent {
icon: 'fa-solid:lock',
link: '/profile/security',
},
{
id: 'two-factor',
title: 'Two Factor Profile',
type: 'basic',
icon: 'fa-solid:user-shield',
link: '/profile/two-factor',
},
{
id: 'developer',
title: 'Developer',
Expand Down
6 changes: 6 additions & 0 deletions src/app/modules/profile/profile.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { AdminComponent } from 'app/modules/profile/admin/admin.component'
import { ProfileDeveloperComponent } from 'app/modules/profile/developer/developer.component'
import { ProfileInfoComponent } from 'app/modules/profile/info/info.component'
import { ProfileSecurityComponent } from 'app/modules/profile/security/security.component'
import { ProfileTwoFactorComponent } from 'app/modules/profile/two-factor/two-factor.component'

export default [
{
Expand All @@ -20,6 +21,11 @@ export default [
title: 'Security',
component: ProfileSecurityComponent,
},
{
path: 'two-factor',
title: 'Two Factor Profile',
component: ProfileTwoFactorComponent,
},
{
path: 'developer',
title: 'Developer',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<div *transloco="let t">
<seed-modal-header [close]="close.bind(this)" [title]="t('Authenticator QR Code')" titleIcon="fa-solid:qrcode"></seed-modal-header>

<div class="flex flex-col gap-4">
<p class="text-sm">
<strong>{{ t('Important') }}</strong> -
{{
t(
'Scan the QR code below using your authenticator app of choice (ex: Google Authenticator, Microsoft Authenticator, Authy, etc...)'
)
}}
</p>

<div class="flex justify-center">
<img class="h-48 w-48" [src]="qrCodeImg" alt="QR Code" />
</div>

<form class="flex flex-wrap items-end gap-4" [formGroup]="codeForm" (ngSubmit)="verify()">
<mat-form-field class="flex-auto">
<mat-label>{{ t('Verification Code') }}</mat-label>
<input matInput formControlName="code" inputmode="numeric" maxlength="6" autocomplete="one-time-code" />
@if (codeForm.controls.code.hasError('required')) {
<mat-error>{{ t('Verification Code') }} {{ t('Required') }}</mat-error>
} @else if (codeForm.controls.code.hasError('pattern')) {
<mat-error>{{ t('Enter the 6-digit code from your authenticator app') }}</mat-error>
}
</mat-form-field>
<button [disabled]="codeForm.invalid || verifying" mat-flat-button color="primary" type="submit">
@if (verifying) {
<mat-progress-spinner class="align-middle mr-2 inline-block" diameter="18" mode="indeterminate"></mat-progress-spinner>
}
<span>{{ t('Verify') }}</span>
</button>
</form>

<p class="text-secondary text-sm">
{{ t('If you are unable to scan the QR code, select a different method below.') }}
</p>
</div>

<div class="mt-6 flex flex-wrap justify-end gap-2">
<mat-dialog-actions>
<button [disabled]="regenerating" (click)="regenerate()" mat-stroked-button>{{ t('Re-Generate Token') }}</button>
</mat-dialog-actions>
<mat-dialog-actions>
<button (click)="close(false)" mat-stroked-button>{{ t('Select a Different Method') }}</button>
</mat-dialog-actions>
<mat-dialog-actions>
<button (click)="close(false)" mat-raised-button color="warn">{{ t('Cancel') }}</button>
</mat-dialog-actions>
</div>
</div>
Loading
Loading