Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2411443ec7 | ||
|
|
586b9c0f1d |
No files matched your search
@@ -249,6 +249,10 @@ context('licensing tests: ', function () {
|
|||||||
|
|
||||||
cy.get('button').contains('Paste licence').click()
|
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
|
// HTTP-format keys are generated as the exact same string for both
|
||||||
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
|
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
|
||||||
// any non-empty matching pair triggers the warning, no real key needed.
|
// 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) {
|
if (testLicenceUserLimits) {
|
||||||
it('4 | User try to register when limit is reached', (done) => {
|
it('4 | User try to register when limit is reached', (done) => {
|
||||||
let keyData = {
|
let keyData = {
|
||||||
@@ -446,7 +705,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
|
|||||||
cy.wait(1000)
|
cy.wait(1000)
|
||||||
isLicensingPage((result: boolean) => {
|
isLicensingPage((result: boolean) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
cy.get('p.key-error')
|
cy.get('.key-error')
|
||||||
.should('contain', text)
|
.should('contain', text)
|
||||||
.then((treeNodes: any) => {
|
.then((treeNodes: any) => {
|
||||||
callback(true)
|
callback(true)
|
||||||
@@ -470,6 +729,10 @@ const verifyLicensingWarning = (text: string, callback: any) => {
|
|||||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||||
cy.get('button').contains('Paste licence').click()
|
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 })
|
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||||
.invoke('val', licenseKey)
|
.invoke('val', licenseKey)
|
||||||
.trigger('input')
|
.trigger('input')
|
||||||
@@ -481,6 +744,30 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
|||||||
cy.get('button.apply-keys').click()
|
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) => {
|
const updateUsersTable = (options: any, callback?: any) => {
|
||||||
visitPage('home')
|
visitPage('home')
|
||||||
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
|
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) => {
|
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
|
||||||
cy.get('td').then((tdNodes: any) => {
|
cy.get('td').then((tdNodes: any) => {
|
||||||
for (let edit of edits) {
|
for (let edit of edits) {
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ const isLicensingPage = (callback: any) => {
|
|||||||
|
|
||||||
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
|
||||||
cy.get('button').contains('Paste licence').click()
|
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 })
|
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
|
||||||
.invoke('val', licenseKey)
|
.invoke('val', licenseKey)
|
||||||
.trigger('input')
|
.trigger('input')
|
||||||
|
|||||||
@@ -1,32 +1,30 @@
|
|||||||
|
import * as base64Converter from 'base64-arraybuffer'
|
||||||
|
|
||||||
export const base64ToArrayBuffer = (base64: string) => {
|
export const base64ToArrayBuffer = (base64: string) => {
|
||||||
return new Promise(async (resolve, reject) => {
|
return new Promise(async (resolve, reject) => {
|
||||||
const dataUrl = "data:application/octet-binary;base64," + base64;
|
const dataUrl = 'data:application/octet-binary;base64,' + base64
|
||||||
|
|
||||||
fetch(dataUrl)
|
fetch(dataUrl)
|
||||||
.then(res => res.arrayBuffer())
|
.then((res) => res.arrayBuffer())
|
||||||
.then(buffer => {
|
.then((buffer) => {
|
||||||
resolve(new Uint8Array(buffer))
|
resolve(new Uint8Array(buffer))
|
||||||
}).catch((err) => {
|
})
|
||||||
|
.catch((err) => {
|
||||||
reject(err)
|
reject(err)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const arrayBufferToBase64 = (arrayBuffer: any) => {
|
// Blob + FileReader.readAsDataURL (a natural alternative here) is
|
||||||
return new Promise((resolve, reject) => {
|
// unreliable in the Cypress runner's iframe - onload/onerror can simply
|
||||||
const blob = new Blob([arrayBuffer])
|
// never fire, leaving the promise permanently unsettled (a silent hang,
|
||||||
|
// surfacing only as Mocha's "done() was never invoked" 30s later with no
|
||||||
const reader = new FileReader();
|
// clue why). base64-arraybuffer's encode() is synchronous and
|
||||||
|
// dependency-free - no browser API involved, so this class of hang isn't
|
||||||
reader.onload = async function(event){
|
// possible - and it's the same library the app itself uses for this exact
|
||||||
if (event.target) {
|
// conversion.
|
||||||
var base64: any = event.target.result
|
export const arrayBufferToBase64 = (
|
||||||
base64 = base64.substring(37, base64.length)
|
arrayBuffer: ArrayBuffer
|
||||||
|
): Promise<string> => {
|
||||||
resolve(base64)
|
return Promise.resolve(base64Converter.encode(arrayBuffer))
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,11 @@
|
|||||||
<div class="card-text">
|
<div class="card-text">
|
||||||
<ng-container *ngSwitchCase="'key'">
|
<ng-container *ngSwitchCase="'key'">
|
||||||
<p class="key-error" *ngIf="!keyError">
|
<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
|
Licence key is invalid. We can't provide you more details at the
|
||||||
moment
|
moment
|
||||||
</p>
|
</p>
|
||||||
@@ -20,6 +25,11 @@
|
|||||||
|
|
||||||
<ng-container *ngSwitchCase="'limit'">
|
<ng-container *ngSwitchCase="'limit'">
|
||||||
<p class="key-error">
|
<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
|
The registered number of users reached the limit specified for your
|
||||||
licence. Please contact
|
licence. Please contact
|
||||||
<contact-link classes="color-green" />
|
<contact-link classes="color-green" />
|
||||||
@@ -34,9 +44,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</ng-container>
|
</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>
|
<strong>SYSSITE:</strong>
|
||||||
<span
|
<span
|
||||||
*ngFor="let id of syssite.value; let i = index"
|
*ngFor="let id of syssite.value; let i = index"
|
||||||
@@ -59,11 +69,6 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
|
|
||||||
<strong>Allowed users:</strong>
|
|
||||||
{{ licenseKeyData.users_allowed }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<clr-tabs>
|
<clr-tabs>
|
||||||
<clr-tab>
|
<clr-tab>
|
||||||
<button clrTabLink>Upload licence</button>
|
<button clrTabLink>Upload licence</button>
|
||||||
@@ -102,56 +107,102 @@
|
|||||||
<clr-tab>
|
<clr-tab>
|
||||||
<button clrTabLink>Paste licence</button>
|
<button clrTabLink>Paste licence</button>
|
||||||
<clr-tab-content>
|
<clr-tab-content>
|
||||||
<form class="clr-form license-key-form">
|
<ng-container *ngIf="keyFormat === 'combined'">
|
||||||
<p>Licence key:</p>
|
<form class="clr-form combined-key-form">
|
||||||
<div class="clr-control-container">
|
<p>Licence key:</p>
|
||||||
<textarea
|
<div class="clr-control-container">
|
||||||
[(ngModel)]="licenceKeyValue"
|
<textarea
|
||||||
(mouseleave)="trimKeys()"
|
[(ngModel)]="combinedKeyValue"
|
||||||
name="license-key-area"
|
(mouseleave)="onCombinedKeyInput()"
|
||||||
placeholder="Paste licence key here"
|
name="combined-key-area"
|
||||||
class="clr-textarea"
|
placeholder="Paste licence key here"
|
||||||
></textarea>
|
class="clr-textarea"
|
||||||
</div>
|
></textarea>
|
||||||
</form>
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
<form class="clr-form activation-key-form">
|
<button
|
||||||
<p>Activation key:</p>
|
type="button"
|
||||||
<div class="clr-control-container">
|
class="btn btn-sm btn-link p-0"
|
||||||
<textarea
|
(click)="toggleKeyFormat()"
|
||||||
[(ngModel)]="activationKeyValue"
|
>
|
||||||
(mouseleave)="trimKeys()"
|
Paste as two separate keys instead
|
||||||
name="activation-key-area"
|
</button>
|
||||||
placeholder="Paste activation key here"
|
</ng-container>
|
||||||
class="clr-textarea"
|
|
||||||
></textarea>
|
<ng-container *ngIf="keyFormat === 'legacy'">
|
||||||
</div>
|
<form class="clr-form license-key-form">
|
||||||
</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-content>
|
||||||
</clr-tab>
|
</clr-tab>
|
||||||
</clr-tabs>
|
</clr-tabs>
|
||||||
|
|
||||||
<p
|
<!--
|
||||||
class="key-error protocol-mismatch-warning"
|
licenseKeyData starts as whatever key is currently active (set once
|
||||||
*ngIf="protocolMismatch === 'requiresHttps'"
|
in ngOnInit), then live-updates to preview a pasted-but-not-yet-
|
||||||
>
|
applied key's own details instead - see refreshKeyPreview().
|
||||||
This key was generated for a secure (HTTPS) connection, but
|
-->
|
||||||
DataController is currently running over HTTP - it will not activate
|
<div *ngIf="licenseKeyData" class="key-details">
|
||||||
here. Access DataController via HTTPS, or contact
|
<h6 cds-text="subsection" class="mt-15 mb-10">Key Details</h6>
|
||||||
<contact-link classes="color-green" />
|
|
||||||
for an HTTP-compatible key.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p
|
<p class="m-0">
|
||||||
class="key-error protocol-mismatch-warning"
|
<strong>Valid until:</strong>
|
||||||
*ngIf="protocolMismatch === 'requiresHttp'"
|
{{ licenseKeyData.valid_until }}
|
||||||
>
|
<span *ngIf="licenseKeyData.demo">(demo/free tier key)</span>
|
||||||
This key was generated for an insecure (HTTP) connection, but this page
|
</p>
|
||||||
is running in a secure browsing context (HTTPS, or localhost) - it will
|
|
||||||
not activate here. Contact
|
<p class="m-0">
|
||||||
<contact-link classes="color-green" />
|
<strong>Allowed users:</strong>
|
||||||
for an HTTPS-compatible key.
|
{{ licenseKeyData.users_allowed }}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div class="card-footer d-flex clr-align-items-center">
|
<div class="card-footer d-flex clr-align-items-center">
|
||||||
@@ -172,6 +223,38 @@
|
|||||||
Continue with free tier
|
Continue with free tier
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</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 { Component, OnInit, ViewEncapsulation } from '@angular/core'
|
||||||
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { AppService, LicenceService, SasService } from '../services'
|
import { AppService, LicenceService, SasService } from '../services'
|
||||||
import { LicenseKeyData } from '../models/LicenseKeyData'
|
import { LicenseKeyData } from '../models/LicenseKeyData'
|
||||||
@@ -7,6 +8,11 @@ import {
|
|||||||
detectLicenceKeyProtocolMismatch,
|
detectLicenceKeyProtocolMismatch,
|
||||||
LicenceKeyProtocolMismatch
|
LicenceKeyProtocolMismatch
|
||||||
} from './utils/detectLicenceKeyProtocolMismatch'
|
} from './utils/detectLicenceKeyProtocolMismatch'
|
||||||
|
import {
|
||||||
|
isCombinedLicenceKey,
|
||||||
|
splitCombinedLicenceKey
|
||||||
|
} from './utils/combinedLicenceKey'
|
||||||
|
import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels'
|
||||||
|
|
||||||
enum LicenseActions {
|
enum LicenseActions {
|
||||||
key = 'key',
|
key = 'key',
|
||||||
@@ -25,12 +31,19 @@ enum LicenseActions {
|
|||||||
export class LicensingComponent implements OnInit {
|
export class LicensingComponent implements OnInit {
|
||||||
public action: LicenseActions | null = null
|
public action: LicenseActions | null = null
|
||||||
|
|
||||||
public licenseErrors: { [key: string]: string } = {
|
// Each message carries its own icon markup, rather than relying on a
|
||||||
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.`,
|
// sibling element in the template - these render via [innerHTML], which
|
||||||
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.`,
|
// replaces all of the host element's children, so a real <clr-icon>
|
||||||
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.`,
|
// placed in the template around the binding would never survive.
|
||||||
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.`
|
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 keyError: string | undefined
|
||||||
public errorDetails: string | undefined
|
public errorDetails: string | undefined
|
||||||
@@ -38,6 +51,12 @@ export class LicensingComponent implements OnInit {
|
|||||||
public licenceKeyValue: string = ''
|
public licenceKeyValue: string = ''
|
||||||
public activationKeyValue: 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 applyingKeys: boolean = false
|
||||||
public protocol: string =
|
public protocol: string =
|
||||||
location.protocol === 'https:'
|
location.protocol === 'https:'
|
||||||
@@ -48,7 +67,6 @@ export class LicensingComponent implements OnInit {
|
|||||||
public currentLicenceKey = this.licenceService.licenceKey
|
public currentLicenceKey = this.licenceService.licenceKey
|
||||||
public currentActivationKey = this.licenceService.activationKey
|
public currentActivationKey = this.licenceService.activationKey
|
||||||
public isAppFreeTier = this.licenceService.isAppFreeTier
|
public isAppFreeTier = this.licenceService.isAppFreeTier
|
||||||
public userCountLimitation = this.licenceService.userCountLimitation
|
|
||||||
|
|
||||||
public licenseKeyData: LicenseKeyData | null = null
|
public licenseKeyData: LicenseKeyData | null = null
|
||||||
|
|
||||||
@@ -64,8 +82,24 @@ export class LicensingComponent implements OnInit {
|
|||||||
private router: Router,
|
private router: Router,
|
||||||
private licenceService: LicenceService,
|
private licenceService: LicenceService,
|
||||||
private sasService: SasService,
|
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 {
|
ngOnInit(): void {
|
||||||
this.licenceKeyValue = this.currentLicenceKey || ''
|
this.licenceKeyValue = this.currentLicenceKey || ''
|
||||||
@@ -93,9 +127,73 @@ export class LicensingComponent implements OnInit {
|
|||||||
this.licenseKeyData = this.licenceService.getLicenseKeyData()
|
this.licenseKeyData = this.licenceService.getLicenseKeyData()
|
||||||
}
|
}
|
||||||
|
|
||||||
public trimKeys() {
|
public async trimKeys() {
|
||||||
this.licenceKeyValue = this.licenceKeyValue.trim()
|
this.licenceKeyValue = this.licenceKeyValue.trim()
|
||||||
this.activationKeyValue = this.activationKeyValue.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[]) {
|
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
|
||||||
@@ -154,7 +252,7 @@ export class LicensingComponent implements OnInit {
|
|||||||
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
|
|
||||||
reader.onload = (evt) => {
|
reader.onload = async (evt) => {
|
||||||
this.licenceFileError = 'Error reading file.'
|
this.licenceFileError = 'Error reading file.'
|
||||||
|
|
||||||
if (!evt || !evt.target) return
|
if (!evt || !evt.target) return
|
||||||
@@ -164,9 +262,20 @@ export class LicensingComponent implements OnInit {
|
|||||||
|
|
||||||
this.licenceFileLoading = false
|
this.licenceFileLoading = false
|
||||||
this.licenceFileError = undefined
|
this.licenceFileError = undefined
|
||||||
const fileArr = evt.target.result.toString().split('\n')
|
|
||||||
this.activationKeyValue = fileArr[1]
|
const fileArr = evt.target.result.toString().trim().split('\n')
|
||||||
this.licenceKeyValue = fileArr[0]
|
|
||||||
|
// 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)
|
reader.readAsText(file)
|
||||||
@@ -205,4 +314,8 @@ export class LicensingComponent implements OnInit {
|
|||||||
!!(window.crypto && window.crypto.subtle)
|
!!(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
|
* '-' means unset
|
||||||
* '0' disabled
|
* '0' disabled
|
||||||
* '1' enabled
|
* '1' enabled
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures'
|
||||||
|
|
||||||
export interface LicenseKeyData {
|
export interface LicenseKeyData {
|
||||||
valid_until: string
|
valid_until: string
|
||||||
users_allowed: number
|
users_allowed: number
|
||||||
@@ -5,5 +7,5 @@ export interface LicenseKeyData {
|
|||||||
site_id_multiple: string[]
|
site_id_multiple: string[]
|
||||||
demo: boolean
|
demo: boolean
|
||||||
hot_license_key: string | undefined
|
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 { freeTierConfig } from '../free-tier.config'
|
||||||
import { AppStoreService } from './app-store.service'
|
import { AppStoreService } from './app-store.service'
|
||||||
import { HelperService } from './helper.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 { LoggerService } from './logger.service'
|
||||||
import { EventService } from './event.service'
|
import { EventService } from './event.service'
|
||||||
|
|
||||||
@@ -255,9 +256,9 @@ export class LicenceService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode and set features that are encoded in the key
|
* Decode and set features that are encoded in the key - accepts either
|
||||||
* featureValue - used as number/Infinity for limit set
|
* an already-issued key's legacy positional string or a newly-issued
|
||||||
* featureToggle - used as boolean, based on 1 or 0 encoded in the key
|
* key's named object, see decodeLicenceFeatures for the format contract.
|
||||||
* @param licenseData from the licence key
|
* @param licenseData from the licence key
|
||||||
*/
|
*/
|
||||||
private decodeLicenceFeatures(licenseData: LicenseKeyData) {
|
private decodeLicenceFeatures(licenseData: LicenseKeyData) {
|
||||||
@@ -270,72 +271,21 @@ export class LicenceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuresMap = licenseData.features.split(',')
|
|
||||||
this._licenceState = {
|
this._licenceState = {
|
||||||
...this._licenceState,
|
...this._licenceState,
|
||||||
viewer_rows_allowed: this.parseFeatureValue(
|
...decodeFeatures(licenseData.features)
|
||||||
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]
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.loggerService.log('Licence state:', this._licenceState)
|
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.
|
* 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,
|
licenseKey: string,
|
||||||
activationKey: string
|
activationKey: string
|
||||||
): Promise<LicenseKeyData> {
|
): 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,
|
.license-key-form,
|
||||||
.activation-key-form {
|
.activation-key-form,
|
||||||
|
.combined-key-form {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|
||||||
.clr-control-container {
|
.clr-control-container {
|
||||||
|
|||||||
Reference in new issue
Block a user