feat(licensing): support combined-key paste, object-format features, and a live key preview

- Accept a single pasted/uploaded combined licence key (base64+gzip of
  licence+activation key), auto-detected in either paste mode, alongside
  the legacy two-field format
- Warn and block applying a key generated for the wrong protocol before
  it ever reaches the backend
- Decode both the legacy positional features string and a new named
  features object, so already-issued keys keep working unchanged
- Show a live "Key Details" preview (validity, users, site IDs, enabled
  features) as a key is typed, pasted, or uploaded
- Fix cypress test setup broken by the paste-format default change, and
  an unreliable FileReader-based base64 conversion in the test helpers
This commit is contained in:
YuryShkoda
2026-08-17 12:04:42 +03:00
parent 9f4b357108
commit 586b9c0f1d
16 changed files with 1041 additions and 151 deletions
+310 -1
View File
@@ -249,6 +249,10 @@ context('licensing tests: ', function () {
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically.
cy.get('button').contains('Paste as two separate keys instead').click()
// HTTP-format keys are generated as the exact same string for both
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
// any non-empty matching pair triggers the warning, no real key needed.
@@ -273,6 +277,229 @@ context('licensing tests: ', function () {
})
})
it('7 | Combined single-string key (default paste format) activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
cy.log(`DEBUG2 isLicensingPage result=${result}`)
if (result) {
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.log(
`DEBUG2 generateCombinedKey resolved len=${combinedKey.length}`
)
inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
} else {
// Fails fast with the actual URL rather than silently hanging
// for 30s until Mocha's own "done() was never invoked" timeout.
cy.url().then((url: string) => {
done(
new Error(`Expected to be on licensing page but url was: ${url}`)
)
})
}
})
})
})
it('8 | Pasting a combined key into the legacy licence-key field alone still auto-detects and activates', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
if (result) {
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('button')
.contains('Paste as two separate keys instead')
.click()
// Only the licence-key field gets the combined string - the
// activation-key field is left empty, proving the split
// populates both fields from this one paste.
cy.get('.license-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.activation-key-form textarea', {
timeout: longerCommandTimeout
}).should(($textarea) => {
expect($textarea.val()).to.equal(keysGen.activationKey)
})
cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
}
})
})
})
it('9 | Uploading a single-line combined-format file activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
if (result) {
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('input[type="file"]').attachFile({
fileContent: new Blob([combinedKey], { type: 'text/plain' }),
fileName: 'datacontroller-licence-combined.txt',
mimeType: 'text/plain'
})
cy.get('button.apply-keys', {
timeout: longerCommandTimeout
}).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
}
})
})
})
it('10 | Key details preview shows for a validly-pasted key and disappears once the key input is cleared', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id,
// Kept to just the two enabled toggles (relying on
// decodeLicenceFeatures' fallback for the rest) to keep the
// encrypted payload well under this suite's RSA-OAEP plaintext
// ceiling - see generateKeys' modulusLength below.
features: { vb: true, fu: true }
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
isLicensingPage((result: boolean) => {
if (result) {
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('contain', 'Valid until:')
.and('contain', 'Allowed users:')
.and('contain', '4')
.and('contain', 'Site ID(s) in this key:')
.and('contain', 'Enabled features:')
.and('contain', 'Viewbox')
.and('contain', 'File Upload')
.invoke('text')
.then((text) => {
expect(text).to.not.contain('Edit Record')
expect(text).to.not.contain('Add Record')
})
cy.get('.combined-key-form textarea')
.invoke('val', '')
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('not.exist')
.then(() => {
done()
})
}
)
}
})
})
})
if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => {
let keyData = {
@@ -446,7 +673,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
cy.wait(1000)
isLicensingPage((result: boolean) => {
if (result) {
cy.get('p.key-error')
cy.get('.key-error')
.should('contain', text)
.then((treeNodes: any) => {
callback(true)
@@ -470,6 +697,10 @@ const verifyLicensingWarning = (text: string, callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
@@ -481,6 +712,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button.apply-keys').click()
}
const inputCombinedKeyPage = (combinedKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path.
cy.get('.combined-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
.should('not.be.undefined')
// mouseleave's handler (onCombinedKeyInput) does async work - gzip
// decompress the split, then decrypt for the key-details preview -
// before licenceKeyValue/activationKeyValue are ready to submit.
// Wait for that preview to land rather than racing "Apply licence
// keys" against the still-in-flight promise.
cy.get('.key-details', { timeout: longerCommandTimeout }).should(
'contain',
'Valid until:'
)
cy.get('button.apply-keys').click()
}
const updateUsersTable = (options: any, callback?: any) => {
visitPage('home')
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
@@ -625,6 +880,60 @@ const generateKeys = async (licenseData: any, resultCallback?: any) => {
})
}
// Mirrors dckey's own encodeCombinedKey()/gzipCompress() (main.js) and
// DC's own splitCombinedLicenceKey() decode side, so this stays a genuine
// end-to-end check of the real format rather than a fixture the app
// happens to accept.
const COMBINED_KEY_PREFIX = 'DCKEY1:'
const generateCombinedKey = async (
licenseKey: string,
activationKey: string
): Promise<string> => {
cy.log('DEBUG2 generateCombinedKey entered')
const payloadBytes = new TextEncoder().encode(
`${licenseKey} ${activationKey}`
)
cy.log(`DEBUG2 CompressionStream available=${typeof CompressionStream}`)
const compressionStream = new CompressionStream('gzip')
cy.log('DEBUG2 CompressionStream constructed')
const writer = compressionStream.writable.getWriter()
cy.log('DEBUG2 writer acquired, calling write')
const writePromise = writer
.write(payloadBytes)
.then(() => {
cy.log('DEBUG2 write() resolved, calling close')
return writer.close()
})
.then(() => cy.log('DEBUG2 close() resolved'))
const readPromise = new Response(compressionStream.readable)
.arrayBuffer()
.then((buf) => {
cy.log(`DEBUG2 response.arrayBuffer() resolved bytes=${buf.byteLength}`)
return buf
})
cy.log('DEBUG2 awaiting Promise.all')
const [, compressedBuffer] = await Promise.all([writePromise, readPromise])
cy.log(`DEBUG2 compression done bytes=${compressedBuffer.byteLength}`)
const compressedBase64 = await arrayBufferToBase64(compressedBuffer)
cy.log(`DEBUG2 base64 done length=${compressedBase64.length}`)
return COMBINED_KEY_PREFIX + compressedBase64
}
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
cy.get('td').then((tdNodes: any) => {
for (let edit of edits) {
+6
View File
@@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined single-string paste is the default format now - switch to
// the legacy two-field layout, since this helper fills licenseKey and
// activationKey as two separate values.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
+21 -23
View File
@@ -1,32 +1,30 @@
import * as base64Converter from 'base64-arraybuffer'
export const base64ToArrayBuffer = (base64: string) => {
return new Promise(async (resolve, reject) => {
const dataUrl = "data:application/octet-binary;base64," + base64;
const dataUrl = 'data:application/octet-binary;base64,' + base64
fetch(dataUrl)
.then(res => res.arrayBuffer())
.then(buffer => {
.then((res) => res.arrayBuffer())
.then((buffer) => {
resolve(new Uint8Array(buffer))
}).catch((err) => {
})
.catch((err) => {
reject(err)
})
})
}
export const arrayBufferToBase64 = (arrayBuffer: any) => {
return new Promise((resolve, reject) => {
const blob = new Blob([arrayBuffer])
const reader = new FileReader();
reader.onload = async function(event){
if (event.target) {
var base64: any = event.target.result
base64 = base64.substring(37, base64.length)
resolve(base64)
}
};
reader.readAsDataURL(blob);
})
}
// Blob + FileReader.readAsDataURL (a natural alternative here) is
// unreliable in the Cypress runner's iframe - onload/onerror can simply
// never fire, leaving the promise permanently unsettled (a silent hang,
// surfacing only as Mocha's "done() was never invoked" 30s later with no
// clue why). base64-arraybuffer's encode() is synchronous and
// dependency-free - no browser API involved, so this class of hang isn't
// possible - and it's the same library the app itself uses for this exact
// conversion.
export const arrayBufferToBase64 = (
arrayBuffer: ArrayBuffer
): Promise<string> => {
return Promise.resolve(base64Converter.encode(arrayBuffer))
}
+134 -51
View File
@@ -5,6 +5,11 @@
<div class="card-text">
<ng-container *ngSwitchCase="'key'">
<p class="key-error" *ngIf="!keyError">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
Licence key is invalid. We can't provide you more details at the
moment
</p>
@@ -20,6 +25,11 @@
<ng-container *ngSwitchCase="'limit'">
<p class="key-error">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
The registered number of users reached the limit specified for your
licence. Please contact
<contact-link classes="color-green" />
@@ -34,9 +44,9 @@
</p>
</ng-container>
<p><strong>Protocol:</strong> {{ protocol }}</p>
<p class="m-0 mt-10"><strong>Protocol:</strong> {{ protocol }}</p>
<p>
<p class="m-0">
<strong>SYSSITE:</strong>
<span
*ngFor="let id of syssite.value; let i = index"
@@ -59,11 +69,6 @@
</a>
</p>
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<clr-tabs>
<clr-tab>
<button clrTabLink>Upload licence</button>
@@ -102,56 +107,102 @@
<clr-tab>
<button clrTabLink>Paste licence</button>
<clr-tab-content>
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<ng-container *ngIf="keyFormat === 'combined'">
<form class="clr-form combined-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="combinedKeyValue"
(mouseleave)="onCombinedKeyInput()"
name="combined-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as two separate keys instead
</button>
</ng-container>
<ng-container *ngIf="keyFormat === 'legacy'">
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as a single key instead
</button>
</ng-container>
</clr-tab-content>
</clr-tab>
</clr-tabs>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttps'"
>
This key was generated for a secure (HTTPS) connection, but
DataController is currently running over HTTP - it will not activate
here. Access DataController via HTTPS, or contact
<contact-link classes="color-green" />
for an HTTP-compatible key.
</p>
<!--
licenseKeyData starts as whatever key is currently active (set once
in ngOnInit), then live-updates to preview a pasted-but-not-yet-
applied key's own details instead - see refreshKeyPreview().
-->
<div *ngIf="licenseKeyData" class="key-details">
<h6 cds-text="subsection" class="mt-15 mb-10">Key Details</h6>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttp'"
>
This key was generated for an insecure (HTTP) connection, but this page
is running in a secure browsing context (HTTPS, or localhost) - it will
not activate here. Contact
<contact-link classes="color-green" />
for an HTTPS-compatible key.
</p>
<p class="m-0">
<strong>Valid until:</strong>
{{ licenseKeyData.valid_until }}
<span *ngIf="licenseKeyData.demo">(demo/free tier key)</span>
</p>
<p class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<p class="m-0">
<strong>Site ID(s) in this key:</strong>
{{
(licenseKeyData.site_id_multiple?.length
? licenseKeyData.site_id_multiple
: [licenseKeyData.site_id]
).join(', ')
}}
</p>
<p *ngIf="enabledFeatures.length" class="m-0">
<strong>Enabled features:</strong>
{{ enabledFeatures.join(', ') }}
</p>
</div>
</div>
<div class="card-footer d-flex clr-align-items-center">
@@ -172,6 +223,38 @@
Continue with free tier
</button>
</div>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttps'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for a secure (HTTPS) connection, but DataController
is currently running over HTTP - it will not activate here. Access
DataController via HTTPS, or contact
<contact-link classes="color-green" />
for an HTTP-compatible key.
</p>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttp'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for an insecure (HTTP) connection, but this page is
running in a secure browsing context (HTTPS, or localhost) - it will not
activate here. Contact
<contact-link classes="color-green" />
for an HTTPS-compatible key.
</p>
</div>
</div>
@@ -0,0 +1,7 @@
.key-error clr-icon {
// clr-icon defaults to vertical-align: middle, which sits it noticeably
// above the text baseline next to it - align its bottom edge with the
// text's instead.
vertical-align: bottom;
margin-right: 10px;
}
+127 -14
View File
@@ -1,4 +1,5 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
import { AppService, LicenceService, SasService } from '../services'
import { LicenseKeyData } from '../models/LicenseKeyData'
@@ -7,6 +8,11 @@ import {
detectLicenceKeyProtocolMismatch,
LicenceKeyProtocolMismatch
} from './utils/detectLicenceKeyProtocolMismatch'
import {
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './utils/combinedLicenceKey'
import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels'
enum LicenseActions {
key = 'key',
@@ -25,12 +31,19 @@ enum LicenseActions {
export class LicensingComponent implements OnInit {
public action: LicenseActions | null = null
public licenseErrors: { [key: string]: string } = {
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
}
// Each message carries its own icon markup, rather than relying on a
// sibling element in the template - these render via [innerHTML], which
// replaces all of the host element's children, so a real <clr-icon>
// placed in the template around the binding would never survive.
private readonly errorIcon = `<clr-icon class="is-error" shape="exclamation-circle" size="26"></clr-icon>`
// SafeHtml, built via bypassSecurityTrustHtml in the constructor (needs
// DomSanitizer, not yet available in a field initializer) - Angular's
// default [innerHTML] sanitizer strips unrecognised tags like <clr-icon>
// (a custom element, not on its standard-tags allowlist), even though it
// leaves the plain <a> in these same strings alone. Safe to bypass since
// this content is entirely hardcoded here, never derived from user input.
public licenseErrors: { [key: string]: SafeHtml }
public keyError: string | undefined
public errorDetails: string | undefined
@@ -38,6 +51,12 @@ export class LicensingComponent implements OnInit {
public licenceKeyValue: string = ''
public activationKeyValue: string = ''
// Purely which INPUT LAYOUT is shown (one field vs two) - not a strict
// parsing gate. Recognising a combined key doesn't depend on this: see
// maybeSplitCombinedKey(), called from both layouts' own input handlers.
public keyFormat: 'combined' | 'legacy' = 'combined'
public combinedKeyValue: string = ''
public applyingKeys: boolean = false
public protocol: string =
location.protocol === 'https:'
@@ -48,7 +67,6 @@ export class LicensingComponent implements OnInit {
public currentLicenceKey = this.licenceService.licenceKey
public currentActivationKey = this.licenceService.activationKey
public isAppFreeTier = this.licenceService.isAppFreeTier
public userCountLimitation = this.licenceService.userCountLimitation
public licenseKeyData: LicenseKeyData | null = null
@@ -64,8 +82,24 @@ export class LicensingComponent implements OnInit {
private router: Router,
private licenceService: LicenceService,
private sasService: SasService,
private appService: AppService
) {}
private appService: AppService,
private sanitizer: DomSanitizer
) {
this.licenseErrors = {
missing: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
expired: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
invalid: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
missmatch: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
)
}
}
ngOnInit(): void {
this.licenceKeyValue = this.currentLicenceKey || ''
@@ -93,9 +127,73 @@ export class LicensingComponent implements OnInit {
this.licenseKeyData = this.licenceService.getLicenseKeyData()
}
public trimKeys() {
public async trimKeys() {
this.licenceKeyValue = this.licenceKeyValue.trim()
this.activationKeyValue = this.activationKeyValue.trim()
// Auto-detect regardless of keyFormat - a combined key pasted into the
// legacy "Licence key" field on its own still gets recognised and
// splits into both fields, rather than requiring the toggle to match.
await this.maybeSplitCombinedKey(this.licenceKeyValue)
await this.refreshKeyPreview()
}
public async onCombinedKeyInput() {
this.combinedKeyValue = this.combinedKeyValue.trim()
// The combined field is the sole source of truth for
// licenceKeyValue/activationKeyValue in this layout - if it's been
// cleared (or isn't a recognisable combined key), there's no key data
// to preview, so clear rather than leave a stale split from an earlier
// paste (which would otherwise keep decrypting to that old key).
const wasSplit = await this.maybeSplitCombinedKey(this.combinedKeyValue)
if (!wasSplit) {
this.licenceKeyValue = ''
this.activationKeyValue = ''
}
await this.refreshKeyPreview()
}
// Decrypts whatever's currently in licenceKeyValue/activationKeyValue
// purely to preview its details (valid_until, users_allowed, ...) before
// the user ever clicks Apply - decryptLicenseKey() has no side effects,
// so this is safe to call speculatively on every input change. Clears
// rather than leaves stale data on failure/incomplete input, since
// showing a previous key's details next to a since-changed paste would
// be actively misleading, not just uninformative.
private async refreshKeyPreview(): Promise<void> {
if (!this.licenceKeyValue || !this.activationKeyValue) {
this.licenseKeyData = null
return
}
try {
this.licenseKeyData = await this.licenceService.decryptLicenseKey(
this.licenceKeyValue,
this.activationKeyValue
)
} catch {
this.licenseKeyData = null
}
}
public toggleKeyFormat() {
this.keyFormat = this.keyFormat === 'combined' ? 'legacy' : 'combined'
}
// Shared by both input layouts' own handlers above - populates
// licenceKeyValue/activationKeyValue when value is recognisably a
// combined key, regardless of which field it was typed/pasted into.
private async maybeSplitCombinedKey(value: string): Promise<boolean> {
if (!isCombinedLicenceKey(value)) return false
const split = await splitCombinedLicenceKey(value)
if (!split) return false
this.licenceKeyValue = split.licenceKey
this.activationKeyValue = split.activationKey
return true
}
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
@@ -154,7 +252,7 @@ export class LicensingComponent implements OnInit {
const reader = new FileReader()
reader.onload = (evt) => {
reader.onload = async (evt) => {
this.licenceFileError = 'Error reading file.'
if (!evt || !evt.target) return
@@ -164,9 +262,20 @@ export class LicensingComponent implements OnInit {
this.licenceFileLoading = false
this.licenceFileError = undefined
const fileArr = evt.target.result.toString().split('\n')
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
const fileArr = evt.target.result.toString().trim().split('\n')
// A combined-key file is a single line - splits into both fields.
// Anything else keeps the existing 2-line legacy file behaviour.
if (
fileArr.length !== 1 ||
!(await this.maybeSplitCombinedKey(fileArr[0]))
) {
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
}
await this.refreshKeyPreview()
}
reader.readAsText(file)
@@ -205,4 +314,8 @@ export class LicensingComponent implements OnInit {
!!(window.crypto && window.crypto.subtle)
)
}
get enabledFeatures(): string[] {
return getEnabledFeatureLabels(this.licenseKeyData?.features)
}
}
@@ -0,0 +1,90 @@
import * as base64Converter from 'base64-arraybuffer'
import {
COMBINED_KEY_PREFIX,
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './combinedLicenceKey'
// Mirrors dckey's own encodeCombinedKey() (main.js) exactly, so these tests
// build fixtures the same way a real generated key would be produced,
// rather than relying on a hardcoded string that could go stale if either
// side's algorithm ever changes.
const gzipCompress = async (bytes: Uint8Array): Promise<ArrayBuffer> => {
const compressionStream = new CompressionStream('gzip')
const writer = compressionStream.writable.getWriter()
writer.write(new Uint8Array(bytes))
writer.close()
return new Response(compressionStream.readable).arrayBuffer()
}
const encodeCombinedLicenceKeyFixture = async (
licenceKey: string,
activationKey: string
): Promise<string> => {
const payloadBytes = new TextEncoder().encode(
`${licenceKey} ${activationKey}`
)
const compressedBytes = await gzipCompress(payloadBytes)
return COMBINED_KEY_PREFIX + base64Converter.encode(compressedBytes)
}
describe('isCombinedLicenceKey', () => {
it('is true for prefixed text', () => {
expect(isCombinedLicenceKey('DCKEY1:abc123')).toBeTrue()
})
it('is false for a plain legacy key', () => {
expect(isCombinedLicenceKey('some-legacy-licence-key')).toBeFalse()
})
it('is false for an empty string', () => {
expect(isCombinedLicenceKey('')).toBeFalse()
})
it('is true even with leading/trailing whitespace around the prefix', () => {
expect(isCombinedLicenceKey(' DCKEY1:abc123 ')).toBeTrue()
})
})
describe('splitCombinedLicenceKey', () => {
it('round-trips a fixture built the same way dckey encodes one', async () => {
const combined = await encodeCombinedLicenceKeyFixture(
'licence-key-value',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence-key-value',
activationKey: 'activation-key-value'
})
})
it('returns null for text without the prefix', async () => {
expect(
await splitCombinedLicenceKey('a-plain-legacy-licence-key')
).toBeNull()
})
it('rejects for prefixed text that is not valid compressed data', async () => {
await expectAsync(
splitCombinedLicenceKey('DCKEY1:not-valid-base64-gzip-data')
).toBeRejected()
})
it('splits on the first space only, not every space', async () => {
// Not a real-world value today (licence/activation keys are base64,
// which never contains a space) - guards the split logic itself rather
// than that assumption.
const combined = await encodeCombinedLicenceKeyFixture(
'licence key with spaces',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence',
activationKey: 'key with spaces activation-key-value'
})
})
})
@@ -0,0 +1,59 @@
import * as base64Converter from 'base64-arraybuffer'
/**
* Format produced by dckey's own encodeCombinedKey() (main.js):
* "DCKEY1:" + base64(gzip(licenceKey + " " + activationKey)). The prefix is
* plain ASCII, not itself compressed/encoded - base64's alphabet never
* contains ":", so it can never collide with the start of a legacy licence
* key or activation key, making format detection unambiguous.
*/
export const COMBINED_KEY_PREFIX = 'DCKEY1:'
export const isCombinedLicenceKey = (text: string): boolean =>
text.trim().startsWith(COMBINED_KEY_PREFIX)
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
const decompressionStream = new DecompressionStream('gzip')
const writer = decompressionStream.writable.getWriter()
// Awaited (unlike a fire-and-forget write) so invalid gzip data rejects
// through this function's own returned promise, not as a separate
// unhandled rejection racing the readable side below.
const result = await Promise.all([
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
new Response(decompressionStream.readable).arrayBuffer()
])
return result[1]
}
/**
* Splits a combined licence key back into its two parts. Returns null (not
* a rejected promise) when text isn't a combined key at all, so callers can
* use it as a "try this, then fall back to the legacy two-field input" check
* without a try/catch for that common case - a prefixed-but-corrupted
* string still rejects, since that's a real error, not a format mismatch.
*/
export const splitCombinedLicenceKey = async (
text: string
): Promise<{ licenceKey: string; activationKey: string } | null> => {
const trimmed = text.trim()
if (!isCombinedLicenceKey(trimmed)) return null
const compressedBytes = base64Converter.decode(
trimmed.slice(COMBINED_KEY_PREFIX.length)
)
const decompressedBytes = await gzipDecompress(compressedBytes)
const payload = new TextDecoder().decode(decompressedBytes)
const separatorIndex = payload.indexOf(' ')
if (separatorIndex === -1) {
throw new Error('Invalid combined licence key: missing separator')
}
return {
licenceKey: payload.slice(0, separatorIndex),
activationKey: payload.slice(separatorIndex + 1)
}
}
@@ -0,0 +1,73 @@
import { getEnabledFeatureLabels } from './getEnabledFeatureLabels'
describe('getEnabledFeatureLabels', () => {
it('returns an empty list when features is undefined', () => {
expect(getEnabledFeatureLabels(undefined)).toEqual([])
})
it('lists only the enabled toggle features from a new-format object', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: false,
er: true,
ar: false
})
).toEqual(['Viewbox', 'Edit Record'])
})
it('lists all four when every toggle is enabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: true,
er: true,
ar: true
})
).toEqual(['Viewbox', 'File Upload', 'Edit Record', 'Add Record'])
})
it('returns an empty list when every toggle is disabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: false,
vbl: null,
ldl: null,
fu: false,
er: false,
ar: false
})
).toEqual([])
})
it('decodes a legacy positional string the same way', () => {
// viewbox=1 (position 6), fileUpload=0 (position 9), editRecord=1
// (position 10), addRecord=0 (position 11) - see LicenceFeaturesMap.
expect(getEnabledFeatureLabels('-,-,-,-,-,-,1,-,-,0,1,0')).toEqual([
'Viewbox',
'Edit Record'
])
})
})
@@ -0,0 +1,24 @@
import {
decodeLicenceFeatures,
LicenceFeaturesObject
} from '../../services/utils/decodeLicenceFeatures'
import { LicenceState } from '../../models/LicenceState'
const TOGGLE_FEATURE_LABELS: { key: keyof LicenceState; label: string }[] = [
{ key: 'viewbox', label: 'Viewbox' },
{ key: 'fileUpload', label: 'File Upload' },
{ key: 'editRecord', label: 'Edit Record' },
{ key: 'addRecord', label: 'Add Record' }
]
export const getEnabledFeatureLabels = (
features: string | LicenceFeaturesObject | undefined
): string[] => {
if (!features) return []
const decoded = decodeLicenceFeatures(features)
return TOGGLE_FEATURE_LABELS.filter(({ key }) => decoded[key]).map(
({ label }) => label
)
}
+5
View File
@@ -15,6 +15,11 @@ export interface LicenceState {
}
/**
* Legacy-format decode table only - newly-issued keys use a named
* LicenceFeaturesObject instead (see decodeLicenceFeatures.ts). Kept
* unchanged and permanently, since every already-issued key still relies
* on these ordinal positions to decode.
*
* '-' means unset
* '0' disabled
* '1' enabled
+3 -1
View File
@@ -1,3 +1,5 @@
import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures'
export interface LicenseKeyData {
valid_until: string
users_allowed: number
@@ -5,5 +7,5 @@ export interface LicenseKeyData {
site_id_multiple: string[]
demo: boolean
hot_license_key: string | undefined
features?: string
features?: string | LicenceFeaturesObject
}
+10 -60
View File
@@ -10,7 +10,8 @@ import { Globvar } from '../models/sas/public-startupservice.model'
import { freeTierConfig } from '../free-tier.config'
import { AppStoreService } from './app-store.service'
import { HelperService } from './helper.service'
import { LicenceFeaturesMap, LicenceState } from '../models/LicenceState'
import { LicenceState } from '../models/LicenceState'
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
import { LoggerService } from './logger.service'
import { EventService } from './event.service'
@@ -255,9 +256,9 @@ export class LicenceService {
}
/**
* Decode and set features that are encoded in the key
* featureValue - used as number/Infinity for limit set
* featureToggle - used as boolean, based on 1 or 0 encoded in the key
* Decode and set features that are encoded in the key - accepts either
* an already-issued key's legacy positional string or a newly-issued
* key's named object, see decodeLicenceFeatures for the format contract.
* @param licenseData from the licence key
*/
private decodeLicenceFeatures(licenseData: LicenseKeyData) {
@@ -270,72 +271,21 @@ export class LicenceService {
}
}
const featuresMap = licenseData.features.split(',')
this._licenceState = {
...this._licenceState,
viewer_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.viewbox_limit]
),
lineage_daily_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: this.parseFeatureToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.fileUpload]
),
editRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.editRecord]
),
addRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.addRecord]
)
...decodeFeatures(licenseData.features)
}
this.loggerService.log('Licence state:', this._licenceState)
}
/**
* Converts licence key feature code value to the number or Infinity
* Used for limiting rows, submits etc.
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 5,10,15
* @returns number
*/
private parseFeatureValue(codeBit: string): number {
if (codeBit === '-') return Infinity
return parseInt(codeBit)
}
/**
* Converts licence key feature code value to the boolean. Depending on if it's 0 or 1
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 1,1,0
* @returns boolean to turn on or off the value
*/
private parseFeatureToggle(codeBit: string): boolean {
return !!parseInt(codeBit)
}
/**
* If decryption fails, key will be marked as invalid and app not activated.
* Public (not just used internally by licensing()) since it's a pure
* decrypt with no side effects - also used to preview a pasted key's
* details before it's actually applied.
*/
private decryptLicenseKey(
public decryptLicenseKey(
licenseKey: string,
activationKey: string
): Promise<LicenseKeyData> {
@@ -0,0 +1,79 @@
import { decodeLicenceFeatures } from './decodeLicenceFeatures'
describe('decodeLicenceFeatures', () => {
// Positional order per LicenceFeaturesMap: viewer_rows_allowed,
// editor_rows_allowed, stage_rows_allowed, history_rows_allowed,
// submit_rows_limit, tables_in_library_limit, viewbox, viewbox_limit,
// lineage_daily_limit, fileUpload, editRecord, addRecord.
const legacyFeaturesString = '10,15,20,-,5,35,1,3,7,1,0,1'
const newFeaturesObject = {
vra: 10,
era: 15,
sra: 20,
hra: null,
srl: 5,
till: 35,
vb: true,
vbl: 3,
ldl: 7,
fu: true,
er: false,
ar: true
}
const expectedDecodedState = {
viewer_rows_allowed: 10,
editor_rows_allowed: 15,
stage_rows_allowed: 20,
history_rows_allowed: Infinity,
submit_rows_limit: 5,
tables_in_library_limit: 35,
viewbox: true,
viewbox_limit: 3,
lineage_daily_limit: 7,
fileUpload: true,
editRecord: false,
addRecord: true
}
it('decodes a legacy positional string exactly as before', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
expectedDecodedState
)
})
it('decodes a new named object', () => {
expect(decodeLicenceFeatures(newFeaturesObject)).toEqual(
expectedDecodedState
)
})
it('produces an identical result for the same licence expressed in either format', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
decodeLicenceFeatures(newFeaturesObject)
)
})
it('maps "-" in a legacy limit position to Infinity', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps null in a new-format limit field to Infinity', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps "1"/"0" in legacy toggle positions to true/false', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
it('passes new-format boolean toggle fields straight through', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
})
@@ -0,0 +1,91 @@
import { LicenceFeaturesMap, LicenceState } from '../../models/LicenceState'
// Field names are abbreviated on the wire to keep the RSA-OAEP-encrypted
// payload small - see "Why abbreviated field names" in
// licence-features-object-plan.md for the full name each one stands for
// and why this is a fixed, append-only contract (same invariant as
// LicenceFeaturesMap's ordinals).
export type LicenceFeaturesObject = {
vra: number | null // viewer_rows_allowed
era: number | null // editor_rows_allowed
sra: number | null // stage_rows_allowed
hra: number | null // history_rows_allowed
srl: number | null // submit_rows_limit
till: number | null // tables_in_library_limit
vb: boolean // viewbox
vbl: number | null // viewbox_limit
ldl: number | null // lineage_daily_limit
fu: boolean // fileUpload
er: boolean // editRecord
ar: boolean // addRecord
}
// Accepts either an already-issued key's positional string
// ("-,-,-,-,-,-,1,-,-,1,1,1", decoded via LicenceFeaturesMap) or a
// newly-issued key's named object - see licence-features-object-plan.md
// for why these two shapes are how format versioning is done here (no
// separate version field: the shapes are already distinguishable).
export const decodeLicenceFeatures = (
features: string | LicenceFeaturesObject
): Partial<LicenceState> => {
if (typeof features === 'object') {
return decodeFeaturesObject(features)
}
return decodeFeaturesString(features)
}
const decodeFeaturesObject = (
features: LicenceFeaturesObject
): Partial<LicenceState> => ({
viewer_rows_allowed: features.vra ?? Infinity,
editor_rows_allowed: features.era ?? Infinity,
stage_rows_allowed: features.sra ?? Infinity,
history_rows_allowed: features.hra ?? Infinity,
submit_rows_limit: features.srl ?? Infinity,
tables_in_library_limit: features.till ?? Infinity,
viewbox: features.vb,
viewbox_limit: features.vbl ?? Infinity,
lineage_daily_limit: features.ldl ?? Infinity,
fileUpload: features.fu,
editRecord: features.er,
addRecord: features.ar
})
// Existing positional-string decode, moved here unchanged from
// LicenceService.decodeLicenceFeatures/parseFeatureValue/parseFeatureToggle.
const decodeFeaturesString = (features: string): Partial<LicenceState> => {
const featuresMap = features.split(',')
const parseValue = (codeBit: string) =>
codeBit === '-' ? Infinity : parseInt(codeBit)
const parseToggle = (codeBit: string) => !!parseInt(codeBit)
return {
viewer_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: parseValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: parseValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: parseValue(featuresMap[LicenceFeaturesMap.viewbox_limit]),
lineage_daily_limit: parseValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: parseToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: parseToggle(featuresMap[LicenceFeaturesMap.fileUpload]),
editRecord: parseToggle(featuresMap[LicenceFeaturesMap.editRecord]),
addRecord: parseToggle(featuresMap[LicenceFeaturesMap.addRecord])
}
}
+2 -1
View File
@@ -2544,7 +2544,8 @@ app-licensing {
}
.license-key-form,
.activation-key-form {
.activation-key-form,
.combined-key-form {
padding: 0;
.clr-control-container {