Skip to content

Frontend/privilege verification enhancements #857

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
2 changes: 2 additions & 0 deletions webroot/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"moment": "^2.30.1",
"numeral": "^2.0.6",
"object.fromentries": "^2.0.2",
"qrcode": "^1.5.4",
"register-service-worker": "^1.7.1",
"text-encoding-shim": "^1.0.5",
"uuid": "^8.3.2",
Expand All @@ -48,6 +49,7 @@
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@types/chai": "^4.2.11",
"@types/mocha": "^5.2.4",
"@types/qrcode": "^1.5.5",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"@vue/cli-plugin-babel": "~5.0.8",
Expand Down
9 changes: 9 additions & 0 deletions webroot/src/components/Page/PageContainer/PageContainer.less
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@
}
}

&.no-page-header {
margin-top: @appHeaderHeight;
padding-top: 0;

@media @tabletWidth {
margin-top: 0;
}
}

header {
position: fixed;
top: 0;
Expand Down
1 change: 1 addition & 0 deletions webroot/src/components/Page/PageContainer/PageContainer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class PageContainer extends Vue {
get includePageHeader(): boolean {
const nonHeaderRouteNames: Array<string> = [
'Logout',
'LicenseeVerification',
];

return (this.isPhone && !nonHeaderRouteNames.includes(this.currentRouteName));
Expand Down
3 changes: 2 additions & 1 deletion webroot/src/components/Page/PageContainer/PageContainer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@

<template>
<div class="page-container" :class="{
'no-top-pad': !shouldPadTop
'no-top-pad': !shouldPadTop,
'no-page-header': !includePageHeader
}">
<header>
<PageHeader v-if="includePageHeader"></PageHeader>
Expand Down
4 changes: 3 additions & 1 deletion webroot/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,9 @@
"practitionerInformation": "Practitioner Information",
"homeStateLicenses": "Home State Licenses",
"activeDate": "Active date",
"privilegeProofFooter": "This document is officially generated by Compact Connect and serves as proof of the practitioner's current licenses and privileges."
"privilegeProofFooter": "This document is officially generated by Compact Connect and serves as proof of the practitioner's current licenses and privileges.",
"publicProfileLink": "View public profile at",
"qrCodeAlt": "QR code linking to public profile"
},
"military": {
"militaryStatusTitle": "Military status",
Expand Down
5 changes: 4 additions & 1 deletion webroot/src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -499,7 +499,10 @@
"confirmSaveCompactTitle": "¿Estás seguro de que deseas marcar este estado como activo?",
"confirmSaveCompactYes": "Sí, marcar como en vivo",
"saveSuccessfulCompact": "Los cambios compactos se guardaron correctamente",
"saveSuccessfulState": "Los cambios de estado se guardaron correctamente"
"saveSuccessfulState": "Los cambios de estado se guardaron correctamente",
"privilegeProofFooter": "Este documento es generado oficialmente por Compact Connect y sirve como prueba de las licencias y privilegios actuales del profesional.",
"publicProfileLink": "Ver perfil público en",
"qrCodeAlt": "Código QR que enlaza al perfil público"
},
"stateUpload": {
"formTitle": "Carga de datos compacta",
Expand Down
12 changes: 8 additions & 4 deletions webroot/src/models/License/License.model.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,8 @@ describe('License model', () => {
expect(license.isDeactivated()).to.equal(false);
expect(license.isCompactEligible()).to.equal(true);
expect(license.licenseTypeAbbreviation()).to.equal('AUD');
expect(license.displayName()).to.equal('Unknown - AUD');
expect(license.displayName()).to.equal('Unknown - audiologist');
expect(license.displayName(', ', true)).to.equal('Unknown, AUD');
expect(license.historyWithFabricatedEvents()).to.matchPattern([
{
type: 'fabricatedEvent',
Expand All @@ -156,7 +157,8 @@ describe('License model', () => {
expect(license).to.be.an.instanceof(License);

// Test methods
expect(license.displayName(' ... ')).to.equal('Colorado ... AUD');
expect(license.displayName(' ... ')).to.equal('Colorado ... audiologist');
expect(license.displayName(' ... ', true)).to.equal('Colorado ... AUD');
});
it('should create a License with specific values through serializer', () => {
const data = {
Expand Down Expand Up @@ -212,7 +214,8 @@ describe('License model', () => {
expect(license.isExpired()).to.equal(true);
expect(license.isDeactivated()).to.equal(false);
expect(license.isCompactEligible()).to.equal(true);
expect(license.displayName()).to.equal('Alabama - AUD');
expect(license.displayName()).to.equal('Alabama - audiologist');
expect(license.displayName(', ', true)).to.equal('Alabama, AUD');
expect(license.licenseTypeAbbreviation()).to.equal('AUD');
expect(license.historyWithFabricatedEvents()).to.matchPattern([
{
Expand Down Expand Up @@ -559,7 +562,8 @@ describe('License model', () => {
expect(license.isExpired()).to.equal(true);
expect(license.isDeactivated()).to.equal(false);
expect(license.isCompactEligible()).to.equal(false);
expect(license.displayName()).to.equal('Nebraska - OTA');
expect(license.displayName()).to.equal('Nebraska - occupational therapy assistant');
expect(license.displayName(', ', true)).to.equal('Nebraska, OTA');
expect(license.licenseTypeAbbreviation()).to.equal('OTA');
expect(license.historyWithFabricatedEvents().length).to.equal(6);
expect(license.historyWithFabricatedEvents()[0].updateType).to.equal('purchased');
Expand Down
6 changes: 4 additions & 2 deletions webroot/src/models/License/License.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,10 @@ export class License implements InterfaceLicense {
return upperCaseAbbrev;
}

public displayName(delimiter = ' - '): string {
return `${this.issueState?.name() || ''}${this.issueState?.name() && this.licenseTypeAbbreviation() ? delimiter : ''}${this.licenseTypeAbbreviation()}`;
public displayName(delimiter = ' - ', displayAbbrev = false): string {
const licenseTypeToShow = displayAbbrev ? this.licenseTypeAbbreviation() : this.licenseType;

return `${this.issueState?.name() || ''}${this.issueState?.name() && licenseTypeToShow ? delimiter : ''}${licenseTypeToShow || ''}`;
}

public historyWithFabricatedEvents(): Array<LicenseHistoryItem> {
Expand Down
35 changes: 35 additions & 0 deletions webroot/src/pages/LicenseeProof/LicenseeProof.less
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@
margin-left: auto;
}

.cell-display-name {
text-transform: capitalize;
}

.cell-id {
font-weight: @fontWeightBold;
font-size: 1.5rem;
}

.cell-title {
color: @lightText;
font-weight: @fontWeight;
Expand All @@ -148,6 +157,32 @@
}
}
}

.qr-code-section {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 2rem;
padding: 2rem;

@media print {
page-break-inside: avoid;
break-inside: avoid;
}

.qr-code-label {
margin: 2rem 0 4rem;
color: @primaryColor;
font-weight: @fontWeightMedium;
font-size: @fontSizeMedium;
text-align: center;

a {
font-weight: @fontWeightBold;
text-decoration: underline;
}
}
}
}

.print-footer {
Expand Down
75 changes: 73 additions & 2 deletions webroot/src/pages/LicenseeProof/LicenseeProof.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// Created by InspiringApps on 5/2/2025.
//

import { Component, Vue } from 'vue-facing-decorator';
import { Component, Vue, Watch } from 'vue-facing-decorator';
import LicenseHomeIcon from '@components/Icons/LicenseHome/LicenseHome.vue';
import PrivilegesIcon from '@components/Icons/LicenseeUser/LicenseeUser.vue';
import UserIcon from '@components/Icons/User/User.vue';
Expand All @@ -15,6 +15,7 @@ import { Licensee } from '@models/Licensee/Licensee.model';
import { State } from '@models/State/State.model';
import { LicenseeUser } from '@/models/LicenseeUser/LicenseeUser.model';
import moment from 'moment';
import QRCode from 'qrcode';

@Component({
name: 'LicenseeProof',
Expand All @@ -25,6 +26,18 @@ import moment from 'moment';
}
})
export default class LicenseeProof extends Vue {
//
// Data
//
qrCodeDataUrl = '';

//
// Lifecycle
//
async mounted() {
await this.generateQRCode();
}

//
// Computed
//
Expand Down Expand Up @@ -70,7 +83,40 @@ export default class LicenseeProof extends Vue {

get licenseePrivileges(): Array<License> {
return this.licensee.privileges?.filter((privilege: License) =>
privilege.status === LicenseStatus.ACTIVE) || [];
privilege.status === LicenseStatus.ACTIVE)
.sort((a: License, b: License) => {
const dateA = moment(a.issueDate);
const dateB = moment(b.issueDate);

return dateB.valueOf() - dateA.valueOf(); // Most recent first
}) || [];
}

get publicProfileUrl(): string {
let url = '';
const { domain } = this.$envConfig;
const licenseeId = this.licensee.id;
const compactType = this.currentCompactType;

if (licenseeId && compactType) {
try {
const resolved = this.$router.resolve({
name: 'LicenseeDetailPublic',
params: {
compact: compactType,
licenseeId
}
});

const urlObj = new URL(`${domain}${resolved.href}`);

url = urlObj.toString();
} catch {
url = '';
}
}

return url;
}

//
Expand All @@ -79,4 +125,29 @@ export default class LicenseeProof extends Vue {
printHandler(): void {
window.print();
}

async generateQRCode(): Promise<void> {
if (this.publicProfileUrl) {
try {
// Get the primary color from CSS custom properties
const primaryColor = getComputedStyle(document.documentElement)
.getPropertyValue('--primary-color').trim();

this.qrCodeDataUrl = await QRCode.toDataURL(this.publicProfileUrl, {
width: 150,
margin: 1,
color: {
dark: primaryColor || '#2459a9', // Fallback to hardcoded value
light: '#ffffff'
}
});
} catch (error) {
this.qrCodeDataUrl = '';
}
}
}

@Watch('publicProfileUrl') async onPublicProfileUrlChange(): Promise<void> {
await this.generateQRCode();
}
}
18 changes: 16 additions & 2 deletions webroot/src/pages/LicenseeProof/LicenseeProof.vue
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
class="row"
>
<div class="cell">
<span>{{ license.displayName(', ') }}</span>
<span class="cell-display-name">{{ license.displayName(', ') }}</span>
</div>
<div class="cell max-gap">
<span class="cell-title">{{ $t('licensing.activeDate') }}</span>
Expand All @@ -82,7 +82,8 @@
class="row"
>
<div class="cell">
<span>{{ privilege.displayName(', ') }}</span>
<span class="cell-display-name">{{ privilege.displayName(', ') }}</span>
<span class="cell-id">{{ privilege.privilegeId }}</span>
</div>
<div class="cell max-gap">
<span class="cell-title">{{ $t('licensing.activeDate') }}</span>
Expand All @@ -93,6 +94,19 @@
<span class="date-text">{{ privilege.expireDateDisplay() }}</span>
</div>
</div>
<div class="qr-code-section" v-if="qrCodeDataUrl">
<img
:src="qrCodeDataUrl"
:alt="$t('licensing.qrCodeAlt')"
class="qr-code-image"
/>
<div class="qr-code-label">
{{ $t('licensing.publicProfileLink') }}
<a :href="publicProfileUrl" target="_blank">
{{ publicProfileUrl }}
</a>
</div>
</div>
</div>
<div class="print-footer">{{ $t('licensing.privilegeProofFooter') }}</div>
</div>
Expand Down
8 changes: 7 additions & 1 deletion webroot/src/styles.common/_colors.less
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
// Blues
@__blue: #2459A9;
@midBlue: @__blue;
@midBlueWcagAA: ;
@darkBlue: #1e2f5e;
@lightBlue: #d1ecf8;
@veryLightBlue: #f6f9ff;
Expand Down Expand Up @@ -77,3 +76,10 @@
@secondaryColor: @midOrange;
@tersiaryColor: #59bfe6;
@rowHighlight: #fafbff;

//================================
//= CSS Custom Properties Export =
//================================
:root {
--primary-color: @primaryColor;
}
Loading