Compare commits
2
Commits
1031ea7ed7
...
licensing
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2411443ec7 | ||
|
|
586b9c0f1d |
@@ -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,261 @@ 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)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - earlier tests may have left the app on it (a
|
||||
// bad key) or already activated (a good one), and this test should
|
||||
// pass either way rather than silently no-op when it's the latter.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
|
||||
(combinedKey) => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
// Chained via .then() rather than called as the next plain
|
||||
// statement - generateCombinedKey is a raw async function that
|
||||
// itself invokes cy commands (cy.log), and Cypress can't
|
||||
// reconcile that promise with the preceding cy.visit()/cy.wait()
|
||||
// unless it's explicitly handed off through a real command chain.
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
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()
|
||||
})
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
// Navigate there explicitly rather than only acting when already on
|
||||
// the licensing page - see test 7's own comment for why.
|
||||
isLicensingPage((result: boolean) => {
|
||||
const proceed = () =>
|
||||
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()
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// See test 7's own comment for why this is chained via .then()
|
||||
// rather than called as the next plain statement.
|
||||
if (!result) {
|
||||
visitPage('licensing/update')
|
||||
cy.wait(2000).then(() => proceed())
|
||||
} else {
|
||||
proceed()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (testLicenceUserLimits) {
|
||||
it('4 | User try to register when limit is reached', (done) => {
|
||||
let keyData = {
|
||||
@@ -446,7 +705,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 +729,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 +744,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 +912,62 @@ 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:'
|
||||
|
||||
// Reads a ReadableStream directly via its own reader, rather than through
|
||||
// `new Response(readable).arrayBuffer()` - Cypress patches fetch/Response
|
||||
// globally for its network-interception features, which appears to hang
|
||||
// a Response built locally around a stream that never touches the network
|
||||
// (writer.write()/close() resolve fine; only the Response-based read never
|
||||
// settles).
|
||||
const streamToArrayBuffer = async (
|
||||
readable: ReadableStream<Uint8Array>
|
||||
): Promise<ArrayBuffer> => {
|
||||
const reader = readable.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let totalLength = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
totalLength += value.length
|
||||
}
|
||||
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
return result.buffer
|
||||
}
|
||||
|
||||
const generateCombinedKey = async (
|
||||
licenseKey: string,
|
||||
activationKey: string
|
||||
): Promise<string> => {
|
||||
const payloadBytes = new TextEncoder().encode(
|
||||
`${licenseKey} ${activationKey}`
|
||||
)
|
||||
|
||||
const compressionStream = new CompressionStream('gzip')
|
||||
const writer = compressionStream.writable.getWriter()
|
||||
const [, compressedBuffer] = await Promise.all([
|
||||
writer.write(payloadBytes).then(() => writer.close()),
|
||||
streamToArrayBuffer(compressionStream.readable)
|
||||
])
|
||||
|
||||
const compressedBase64 = await arrayBufferToBase64(compressedBuffer)
|
||||
|
||||
return COMBINED_KEY_PREFIX + compressedBase64
|
||||
}
|
||||
|
||||
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
|
||||
cy.get('td').then((tdNodes: any) => {
|
||||
for (let edit of edits) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,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])
|
||||
}
|
||||
}
|
||||
@@ -2544,7 +2544,8 @@ app-licensing {
|
||||
}
|
||||
|
||||
.license-key-form,
|
||||
.activation-key-form {
|
||||
.activation-key-form,
|
||||
.combined-key-form {
|
||||
padding: 0;
|
||||
|
||||
.clr-control-container {
|
||||
|
||||
Reference in New Issue
Block a user