@auxilium/datalynk-client 1.6.3 → 1.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/api.d.ts +9 -0
- package/dist/api.d.ts.map +1 -1
- package/dist/auth.d.ts +6 -2
- package/dist/auth.d.ts.map +1 -1
- package/dist/chat-gui.d.ts +11 -0
- package/dist/chat-gui.d.ts.map +1 -1
- package/dist/chat-gui.view.d.ts +3 -1
- package/dist/chat-gui.view.d.ts.map +1 -1
- package/dist/chat.cjs +212 -43
- package/dist/chat.d.ts +13 -0
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.mjs +212 -43
- package/dist/index.cjs +438 -98
- package/dist/index.mjs +438 -98
- package/dist/offline-auth.d.ts +68 -4
- package/dist/offline-auth.d.ts.map +1 -1
- package/dist/service.worker.mjs +2 -1
- package/dist/slice.d.ts.map +1 -1
- package/dist/utils.d.ts +13 -0
- package/dist/utils.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/index.mjs
CHANGED
|
@@ -189,6 +189,8 @@ class Database {
|
|
|
189
189
|
constructor(database, tables, version2) {
|
|
190
190
|
__publicField(this, "connection");
|
|
191
191
|
__publicField(this, "tables");
|
|
192
|
+
/** Encrypts values at rest when set */
|
|
193
|
+
__publicField(this, "cipher");
|
|
192
194
|
this.database = database;
|
|
193
195
|
this.version = version2;
|
|
194
196
|
this.tables = tables.map((table) => typeof table === "object" ? { ...table, name: table.name.toString() } : { name: table.toString() });
|
|
@@ -254,17 +256,29 @@ class Table {
|
|
|
254
256
|
}
|
|
255
257
|
});
|
|
256
258
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
+
seal(value) {
|
|
260
|
+
const cipher = this.database.cipher;
|
|
261
|
+
return (cipher == null ? void 0 : cipher.enabled) ? cipher.seal(value) : Promise.resolve(value);
|
|
259
262
|
}
|
|
260
|
-
|
|
261
|
-
|
|
263
|
+
open(value) {
|
|
264
|
+
const cipher = this.database.cipher;
|
|
265
|
+
return cipher ? cipher.open(value) : Promise.resolve(value);
|
|
262
266
|
}
|
|
263
|
-
|
|
264
|
-
|
|
267
|
+
async add(value, key) {
|
|
268
|
+
const sealed = await this.seal(value);
|
|
269
|
+
return this.tx((store) => store.add(sealed, key));
|
|
265
270
|
}
|
|
266
|
-
|
|
267
|
-
|
|
271
|
+
async put(key, value) {
|
|
272
|
+
const sealed = await this.seal(value);
|
|
273
|
+
return this.tx((store) => store.put(sealed, key));
|
|
274
|
+
}
|
|
275
|
+
async get(key) {
|
|
276
|
+
return await this.open(await this.tx((store) => store.get(key), true));
|
|
277
|
+
}
|
|
278
|
+
/** All readable values. Values sealed with another key are skipped. */
|
|
279
|
+
async getAll() {
|
|
280
|
+
const values = await Promise.all((await this.tx((store) => store.getAll(), true)).map((v) => this.open(v)));
|
|
281
|
+
return values.filter((v) => v !== void 0);
|
|
268
282
|
}
|
|
269
283
|
getAllKeys() {
|
|
270
284
|
return this.tx((store) => store.getAllKeys(), true);
|
|
@@ -275,6 +289,20 @@ class Table {
|
|
|
275
289
|
clear() {
|
|
276
290
|
return this.tx((store) => store.clear());
|
|
277
291
|
}
|
|
292
|
+
/** Replace the whole table in one transaction (values are sealed first, so nothing is lost while locked) */
|
|
293
|
+
async replace(entries) {
|
|
294
|
+
const sealed = await Promise.all(entries.map(async ([key, value]) => [key, await this.seal(value)]));
|
|
295
|
+
const db = await this.database.connection;
|
|
296
|
+
await new Promise((resolve, reject) => {
|
|
297
|
+
const tx = db.transaction(this.name, "readwrite");
|
|
298
|
+
const store = tx.objectStore(this.name);
|
|
299
|
+
store.clear();
|
|
300
|
+
sealed.forEach(([key, value]) => store.put(value, key));
|
|
301
|
+
tx.oncomplete = () => resolve();
|
|
302
|
+
tx.onerror = () => reject(tx.error);
|
|
303
|
+
tx.onabort = () => reject(tx.error);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
278
306
|
count() {
|
|
279
307
|
return this.tx((store) => store.count(), true);
|
|
280
308
|
}
|
|
@@ -1028,6 +1056,9 @@ var BehaviorSubject = function(_super) {
|
|
|
1028
1056
|
};
|
|
1029
1057
|
return BehaviorSubject2;
|
|
1030
1058
|
}(Subject);
|
|
1059
|
+
var EMPTY = new Observable(function(subscriber) {
|
|
1060
|
+
return subscriber.complete();
|
|
1061
|
+
});
|
|
1031
1062
|
var EmptyError = createErrorClass(function(_super) {
|
|
1032
1063
|
return function EmptyErrorImpl() {
|
|
1033
1064
|
_super(this);
|
|
@@ -1055,6 +1086,23 @@ function lastValueFrom(source, config2) {
|
|
|
1055
1086
|
});
|
|
1056
1087
|
});
|
|
1057
1088
|
}
|
|
1089
|
+
function firstValueFrom(source, config2) {
|
|
1090
|
+
return new Promise(function(resolve, reject) {
|
|
1091
|
+
var subscriber = new SafeSubscriber({
|
|
1092
|
+
next: function(value) {
|
|
1093
|
+
resolve(value);
|
|
1094
|
+
subscriber.unsubscribe();
|
|
1095
|
+
},
|
|
1096
|
+
error: reject,
|
|
1097
|
+
complete: function() {
|
|
1098
|
+
{
|
|
1099
|
+
reject(new EmptyError());
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
});
|
|
1103
|
+
source.subscribe(subscriber);
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1058
1106
|
function filter(predicate, thisArg) {
|
|
1059
1107
|
return operate(function(source, subscriber) {
|
|
1060
1108
|
var index = 0;
|
|
@@ -1063,6 +1111,21 @@ function filter(predicate, thisArg) {
|
|
|
1063
1111
|
}));
|
|
1064
1112
|
});
|
|
1065
1113
|
}
|
|
1114
|
+
function take(count) {
|
|
1115
|
+
return count <= 0 ? function() {
|
|
1116
|
+
return EMPTY;
|
|
1117
|
+
} : operate(function(source, subscriber) {
|
|
1118
|
+
var seen = 0;
|
|
1119
|
+
source.subscribe(createOperatorSubscriber(subscriber, function(value) {
|
|
1120
|
+
if (++seen <= count) {
|
|
1121
|
+
subscriber.next(value);
|
|
1122
|
+
if (count <= seen) {
|
|
1123
|
+
subscriber.complete();
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
}));
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1066
1129
|
function distinctUntilChanged(comparator, keySelector) {
|
|
1067
1130
|
if (keySelector === void 0) {
|
|
1068
1131
|
keySelector = identity;
|
|
@@ -1544,52 +1607,304 @@ __publicField(_LoginPrompt, "template", (options) => {
|
|
|
1544
1607
|
`;
|
|
1545
1608
|
});
|
|
1546
1609
|
let LoginPrompt = _LoginPrompt;
|
|
1547
|
-
const
|
|
1610
|
+
const RECORD_KEY = "datalynk-offline-login";
|
|
1611
|
+
const PROFILE_KEY = "datalynk-user";
|
|
1612
|
+
const VAULT_DB = "datalynk-vault";
|
|
1613
|
+
const VAULT_STORE = "keys";
|
|
1614
|
+
const DEVICE_KEY = "device";
|
|
1548
1615
|
const ITERATIONS = 31e4;
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
}
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
try {
|
|
1567
|
-
record = JSON.parse(saved);
|
|
1568
|
-
} catch {
|
|
1569
|
-
return false;
|
|
1570
|
-
}
|
|
1571
|
-
if (record.realm !== realm || record.login !== login.trim().toLowerCase() || record.uid !== uid || !Array.isArray(record.salt) || !Array.isArray(record.hash)) return false;
|
|
1572
|
-
const candidate = await hash(password, new Uint8Array(record.salt));
|
|
1573
|
-
if (candidate.length !== record.hash.length) return false;
|
|
1574
|
-
let difference = 0;
|
|
1575
|
-
for (let i = 0; i < candidate.length; i++) difference |= candidate[i] ^ record.hash[i];
|
|
1576
|
-
return difference === 0;
|
|
1616
|
+
const DAY = 864e5;
|
|
1617
|
+
const offlineLoginMessages = {
|
|
1618
|
+
"none": "Offline login is not available on this device. Connect to the internet to sign in.",
|
|
1619
|
+
"wrong-user": "Offline login is only available for the last user who signed in on this device.",
|
|
1620
|
+
"expired": "Offline login has expired on this device. Connect to the internet to sign in.",
|
|
1621
|
+
"locked": "Offline login is locked after too many attempts. Connect to the internet to sign in.",
|
|
1622
|
+
"bad-password": "Incorrect password."
|
|
1623
|
+
};
|
|
1624
|
+
const toB64 = (bytes) => {
|
|
1625
|
+
let out = "";
|
|
1626
|
+
new Uint8Array(bytes).forEach((b) => out += String.fromCharCode(b));
|
|
1627
|
+
return btoa(out);
|
|
1628
|
+
};
|
|
1629
|
+
const fromB64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
|
|
1630
|
+
const normalizeLogin = (login) => (login || "").trim().toLowerCase();
|
|
1631
|
+
function isSealed(value) {
|
|
1632
|
+
return !!value && typeof value == "object" && value.__dlv === 1 && "iv" in value && "ct" in value;
|
|
1577
1633
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1634
|
+
class OfflineVault {
|
|
1635
|
+
constructor(enabled, options = {}, startOffline) {
|
|
1636
|
+
/** False when offline login is disabled or the environment lacks WebCrypto/IndexedDB/localStorage */
|
|
1637
|
+
__publicField(this, "enabled");
|
|
1638
|
+
/** True once the data key is available in memory */
|
|
1639
|
+
__publicField(this, "unlocked$", new BehaviorSubject(false));
|
|
1640
|
+
/** Resolves once startup key loading has finished */
|
|
1641
|
+
__publicField(this, "ready");
|
|
1642
|
+
__publicField(this, "dek", null);
|
|
1643
|
+
__publicField(this, "days");
|
|
1644
|
+
__publicField(this, "attempts");
|
|
1645
|
+
this.enabled = enabled && typeof crypto != "undefined" && !!crypto.subtle && typeof indexedDB != "undefined" && typeof localStorage != "undefined";
|
|
1646
|
+
this.days = options.days ?? 7;
|
|
1647
|
+
this.attempts = options.attempts ?? 5;
|
|
1648
|
+
if (startOffline === void 0) startOffline = typeof navigator != "undefined" && navigator.onLine === false;
|
|
1649
|
+
this.ready = this.enabled ? this.init(startOffline).catch((error) => {
|
|
1650
|
+
console.error("Unable to open the Datalynk offline vault", error);
|
|
1651
|
+
}) : Promise.resolve();
|
|
1652
|
+
}
|
|
1653
|
+
get unlocked() {
|
|
1654
|
+
return this.unlocked$.getValue();
|
|
1655
|
+
}
|
|
1656
|
+
/** A saved offline login exists, but this session has not unlocked it yet */
|
|
1657
|
+
get needsPassword() {
|
|
1658
|
+
return this.enabled && !this.unlocked && !!this.readRecord();
|
|
1659
|
+
}
|
|
1660
|
+
/** Wait until the data key is available (resolves immediately when the vault is disabled) */
|
|
1661
|
+
whenUnlocked() {
|
|
1662
|
+
if (!this.enabled || this.unlocked) return Promise.resolve(true);
|
|
1663
|
+
return firstValueFrom(this.unlocked$.pipe(filter(Boolean)));
|
|
1664
|
+
}
|
|
1665
|
+
/** Saved offline login matches this user */
|
|
1666
|
+
has(realm, uid) {
|
|
1667
|
+
const record = this.readRecord();
|
|
1668
|
+
return !!record && record.realm === realm && record.uid === uid;
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* Called after a successful online password login. Wraps the data key with the
|
|
1672
|
+
* password so this user can sign in offline later.
|
|
1673
|
+
*/
|
|
1674
|
+
async rememberLogin(realm, login, uid, password) {
|
|
1675
|
+
if (!this.enabled) return;
|
|
1676
|
+
await this.ready;
|
|
1677
|
+
const record = this.readRecord();
|
|
1678
|
+
let dek = null;
|
|
1679
|
+
if (record && record.realm === realm && record.uid === uid) {
|
|
1680
|
+
dek = await this.unwrapWithPassword(record, password).catch(() => null);
|
|
1681
|
+
if (!dek) dek = this.dek;
|
|
1682
|
+
} else if (!record) {
|
|
1683
|
+
dek = this.dek;
|
|
1684
|
+
}
|
|
1685
|
+
if (!dek) dek = await this.newDataKey();
|
|
1686
|
+
const salt = crypto.getRandomValues(new Uint8Array(16));
|
|
1687
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1688
|
+
const kek = await this.passwordKey(password, salt);
|
|
1689
|
+
const wrapped = await crypto.subtle.wrapKey("raw", dek, kek, { name: "AES-GCM", iv });
|
|
1690
|
+
this.writeRecord({
|
|
1691
|
+
v: 2,
|
|
1692
|
+
realm,
|
|
1693
|
+
uid,
|
|
1694
|
+
login: normalizeLogin(login),
|
|
1695
|
+
salt: toB64(salt),
|
|
1696
|
+
iv: toB64(iv),
|
|
1697
|
+
wrapped: toB64(wrapped),
|
|
1698
|
+
verifiedAt: Date.now(),
|
|
1699
|
+
failures: 0
|
|
1700
|
+
});
|
|
1701
|
+
await this.setKey(dek);
|
|
1702
|
+
}
|
|
1703
|
+
/** Try to unlock offline with a password */
|
|
1704
|
+
async unlockOffline(realm, login, uid, password) {
|
|
1705
|
+
if (!this.enabled) return "none";
|
|
1706
|
+
await this.ready;
|
|
1707
|
+
const record = this.readRecord();
|
|
1708
|
+
if (!record) return "none";
|
|
1709
|
+
if (record.realm !== realm || record.login !== normalizeLogin(login) || uid != null && record.uid !== uid) return "wrong-user";
|
|
1710
|
+
if (record.locked) return "locked";
|
|
1711
|
+
if (Date.now() - record.verifiedAt > this.days * DAY) return "expired";
|
|
1712
|
+
try {
|
|
1713
|
+
const dek = await this.unwrapWithPassword(record, password);
|
|
1714
|
+
this.writeRecord({ ...record, failures: 0 });
|
|
1715
|
+
await this.setKey(dek);
|
|
1716
|
+
return "ok";
|
|
1717
|
+
} catch {
|
|
1718
|
+
const failures = (record.failures || 0) + 1;
|
|
1719
|
+
const locked = failures >= this.attempts;
|
|
1720
|
+
this.writeRecord({ ...record, failures, locked });
|
|
1721
|
+
return locked ? "locked" : "bad-password";
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
/** The server confirmed this user's session: restart the offline window */
|
|
1725
|
+
touch(realm, uid) {
|
|
1726
|
+
if (!this.enabled || realm == null || uid == null) return;
|
|
1727
|
+
const record = this.readRecord();
|
|
1728
|
+
if (record && record.realm === realm && record.uid === uid) this.writeRecord({ ...record, verifiedAt: Date.now() });
|
|
1729
|
+
}
|
|
1730
|
+
/** Drop the in-memory and device keys but keep the password copy (queued work stays recoverable) */
|
|
1731
|
+
async lock() {
|
|
1732
|
+
this.dek = null;
|
|
1733
|
+
if (this.unlocked) this.unlocked$.next(false);
|
|
1734
|
+
if (this.enabled) await this.deleteDevice();
|
|
1735
|
+
}
|
|
1736
|
+
/** Remove offline login completely; anything sealed becomes unreadable */
|
|
1737
|
+
async forget() {
|
|
1738
|
+
if (typeof localStorage != "undefined") {
|
|
1739
|
+
localStorage.removeItem(RECORD_KEY);
|
|
1740
|
+
localStorage.removeItem(PROFILE_KEY);
|
|
1741
|
+
}
|
|
1742
|
+
await this.lock();
|
|
1743
|
+
}
|
|
1744
|
+
/** Encrypt a value for IndexedDB (passes through when disabled) */
|
|
1745
|
+
async seal(value) {
|
|
1746
|
+
if (!this.enabled || value === void 0) return value;
|
|
1747
|
+
await this.whenUnlocked();
|
|
1748
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1749
|
+
const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, this.dek, new TextEncoder().encode(JSON.stringify(value)));
|
|
1750
|
+
return { __dlv: 1, iv, ct };
|
|
1751
|
+
}
|
|
1752
|
+
/** Decrypt a value from IndexedDB. Unreadable values (old key / other user) return undefined. */
|
|
1753
|
+
async open(value) {
|
|
1754
|
+
if (!isSealed(value)) return value;
|
|
1755
|
+
if (!this.enabled) return void 0;
|
|
1756
|
+
await this.whenUnlocked();
|
|
1757
|
+
try {
|
|
1758
|
+
const iv = typeof value.iv == "string" ? fromB64(value.iv) : value.iv;
|
|
1759
|
+
const ct = typeof value.ct == "string" ? fromB64(value.ct) : value.ct;
|
|
1760
|
+
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, this.dek, ct);
|
|
1761
|
+
return JSON.parse(new TextDecoder().decode(plain));
|
|
1762
|
+
} catch {
|
|
1763
|
+
return void 0;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
/** Save the cached user profile (sealed when enabled) */
|
|
1767
|
+
async setProfile(user) {
|
|
1768
|
+
if (typeof localStorage == "undefined") return;
|
|
1769
|
+
if (user == null) return localStorage.removeItem(PROFILE_KEY);
|
|
1770
|
+
if (!this.enabled) return localStorage.setItem(PROFILE_KEY, JSON.stringify(user));
|
|
1771
|
+
const sealed = await this.seal(user);
|
|
1772
|
+
localStorage.setItem(PROFILE_KEY, JSON.stringify({ __dlv: 1, iv: toB64(sealed.iv), ct: toB64(sealed.ct) }));
|
|
1773
|
+
}
|
|
1774
|
+
/** Read the cached user profile; null while locked */
|
|
1775
|
+
async getProfile() {
|
|
1776
|
+
if (typeof localStorage == "undefined") return null;
|
|
1777
|
+
let saved;
|
|
1778
|
+
try {
|
|
1779
|
+
saved = JSON.parse(localStorage.getItem(PROFILE_KEY) || "null");
|
|
1780
|
+
} catch {
|
|
1781
|
+
return null;
|
|
1782
|
+
}
|
|
1783
|
+
if (!isSealed(saved)) return this.enabled ? null : saved;
|
|
1784
|
+
if (!this.unlocked) return null;
|
|
1785
|
+
return await this.open(saved) ?? null;
|
|
1786
|
+
}
|
|
1787
|
+
// ----- internals -----
|
|
1788
|
+
async init(startOffline) {
|
|
1789
|
+
if (startOffline) {
|
|
1790
|
+
await this.deleteDevice();
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const device = await this.readDevice();
|
|
1794
|
+
if (device) {
|
|
1795
|
+
try {
|
|
1796
|
+
const dek = await crypto.subtle.unwrapKey(
|
|
1797
|
+
"raw",
|
|
1798
|
+
device.wrapped,
|
|
1799
|
+
device.key,
|
|
1800
|
+
{ name: "AES-GCM", iv: device.iv },
|
|
1801
|
+
{ name: "AES-GCM" },
|
|
1802
|
+
true,
|
|
1803
|
+
["encrypt", "decrypt"]
|
|
1804
|
+
);
|
|
1805
|
+
this.dek = dek;
|
|
1806
|
+
this.unlocked$.next(true);
|
|
1807
|
+
return;
|
|
1808
|
+
} catch {
|
|
1809
|
+
await this.deleteDevice();
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
if (!this.readRecord()) await this.setKey(await this.newDataKey());
|
|
1813
|
+
}
|
|
1814
|
+
async setKey(dek) {
|
|
1815
|
+
this.dek = dek;
|
|
1816
|
+
try {
|
|
1817
|
+
await this.writeDevice(dek);
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
console.warn("Unable to store the Datalynk device key; offline data will need the password", error);
|
|
1820
|
+
}
|
|
1821
|
+
if (!this.unlocked) this.unlocked$.next(true);
|
|
1822
|
+
}
|
|
1823
|
+
newDataKey() {
|
|
1824
|
+
return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
|
|
1825
|
+
}
|
|
1826
|
+
async passwordKey(password, salt) {
|
|
1827
|
+
const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
|
|
1828
|
+
return crypto.subtle.deriveKey(
|
|
1829
|
+
{ name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
|
|
1830
|
+
base,
|
|
1831
|
+
{ name: "AES-GCM", length: 256 },
|
|
1832
|
+
false,
|
|
1833
|
+
["wrapKey", "unwrapKey"]
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
async unwrapWithPassword(record, password) {
|
|
1837
|
+
const kek = await this.passwordKey(password, fromB64(record.salt));
|
|
1838
|
+
return crypto.subtle.unwrapKey(
|
|
1839
|
+
"raw",
|
|
1840
|
+
fromB64(record.wrapped),
|
|
1841
|
+
kek,
|
|
1842
|
+
{ name: "AES-GCM", iv: fromB64(record.iv) },
|
|
1843
|
+
{ name: "AES-GCM" },
|
|
1844
|
+
true,
|
|
1845
|
+
["encrypt", "decrypt"]
|
|
1846
|
+
);
|
|
1847
|
+
}
|
|
1848
|
+
readRecord() {
|
|
1849
|
+
if (typeof localStorage == "undefined") return null;
|
|
1850
|
+
try {
|
|
1851
|
+
const record = JSON.parse(localStorage.getItem(RECORD_KEY) || "null");
|
|
1852
|
+
if ((record == null ? void 0 : record.v) === 2) return record;
|
|
1853
|
+
if (record) localStorage.removeItem(RECORD_KEY);
|
|
1854
|
+
} catch {
|
|
1855
|
+
localStorage.removeItem(RECORD_KEY);
|
|
1856
|
+
}
|
|
1857
|
+
return null;
|
|
1858
|
+
}
|
|
1859
|
+
writeRecord(record) {
|
|
1860
|
+
localStorage.setItem(RECORD_KEY, JSON.stringify(record));
|
|
1861
|
+
}
|
|
1862
|
+
vaultDb() {
|
|
1863
|
+
return new Promise((resolve, reject) => {
|
|
1864
|
+
const request = indexedDB.open(VAULT_DB, 1);
|
|
1865
|
+
request.onupgradeneeded = () => request.result.createObjectStore(VAULT_STORE);
|
|
1866
|
+
request.onsuccess = () => resolve(request.result);
|
|
1867
|
+
request.onerror = () => reject(request.error);
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
async vaultTx(mode, op) {
|
|
1871
|
+
const db = await this.vaultDb();
|
|
1872
|
+
try {
|
|
1873
|
+
return await new Promise((resolve, reject) => {
|
|
1874
|
+
const tx = db.transaction(VAULT_STORE, mode);
|
|
1875
|
+
const request = op(tx.objectStore(VAULT_STORE));
|
|
1876
|
+
tx.oncomplete = () => resolve(request.result);
|
|
1877
|
+
tx.onerror = () => reject(tx.error);
|
|
1878
|
+
tx.onabort = () => reject(tx.error);
|
|
1879
|
+
});
|
|
1880
|
+
} finally {
|
|
1881
|
+
db.close();
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1884
|
+
async readDevice() {
|
|
1885
|
+
try {
|
|
1886
|
+
return await this.vaultTx("readonly", (store) => store.get(DEVICE_KEY)) || null;
|
|
1887
|
+
} catch {
|
|
1888
|
+
return null;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
async writeDevice(dek) {
|
|
1892
|
+
const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
|
|
1893
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
1894
|
+
const wrapped = await crypto.subtle.wrapKey("raw", dek, key, { name: "AES-GCM", iv });
|
|
1895
|
+
await this.vaultTx("readwrite", (store) => store.put({ key, iv, wrapped }, DEVICE_KEY));
|
|
1896
|
+
}
|
|
1897
|
+
async deleteDevice() {
|
|
1898
|
+
try {
|
|
1899
|
+
await this.vaultTx("readwrite", (store) => store.delete(DEVICE_KEY));
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
console.warn("Unable to remove the Datalynk device key", error);
|
|
1902
|
+
}
|
|
1584
1903
|
}
|
|
1585
|
-
}
|
|
1586
|
-
function forgetOfflineLogin() {
|
|
1587
|
-
localStorage.removeItem(KEY);
|
|
1588
1904
|
}
|
|
1589
1905
|
class Auth {
|
|
1590
1906
|
constructor(api) {
|
|
1591
1907
|
__publicField(this, "onlinePrompt");
|
|
1592
|
-
__publicField(this, "offlineUnlocked", false);
|
|
1593
1908
|
__publicField(this, "wasOffline", false);
|
|
1594
1909
|
/** Current user as an observable */
|
|
1595
1910
|
__publicField(this, "user$", new BehaviorSubject(void 0));
|
|
@@ -1599,21 +1914,12 @@ class Auth {
|
|
|
1599
1914
|
if (status === "offline" || status === "unavailable") this.wasOffline = true;
|
|
1600
1915
|
if (status !== "online" || !this.wasOffline) return;
|
|
1601
1916
|
this.wasOffline = false;
|
|
1602
|
-
const wasUnlocked = this.offlineUnlocked;
|
|
1603
1917
|
const previousUser = this.user;
|
|
1604
|
-
this.offlineUnlocked = false;
|
|
1605
|
-
this.user = void 0;
|
|
1606
1918
|
try {
|
|
1607
|
-
|
|
1608
|
-
const identity2 = this.api.jwtPayload;
|
|
1609
|
-
if (!user || user.guest || !identity2 || !hasOfflineLogin(identity2.realm, identity2.uid))
|
|
1610
|
-
forgetOfflineLogin();
|
|
1611
|
-
this.user = user;
|
|
1919
|
+
this.user = await this.current(this.api.token, true);
|
|
1612
1920
|
} catch (error) {
|
|
1613
|
-
if (this.api.status
|
|
1614
|
-
else {
|
|
1921
|
+
if (this.api.status !== "unauthorized") {
|
|
1615
1922
|
this.wasOffline = true;
|
|
1616
|
-
this.offlineUnlocked = wasUnlocked;
|
|
1617
1923
|
this.user = previousUser;
|
|
1618
1924
|
}
|
|
1619
1925
|
console.error("Unable to recheck the Datalynk user", error);
|
|
@@ -1630,11 +1936,22 @@ class Auth {
|
|
|
1630
1936
|
});
|
|
1631
1937
|
if ((_a = this.api.options.offline) == null ? void 0 : _a.length)
|
|
1632
1938
|
this.user$.pipe(filter((u) => u !== void 0)).subscribe((u) => {
|
|
1633
|
-
if (u == null)
|
|
1634
|
-
|
|
1635
|
-
|
|
1939
|
+
if (u == null && !this.api.online) return;
|
|
1940
|
+
const vault = this.api.vault;
|
|
1941
|
+
if (vault) vault.setProfile(u).catch((error) => console.warn("Unable to cache the Datalynk user", error));
|
|
1942
|
+
else if (u == null) localStorage.removeItem("datalynk-user");
|
|
1943
|
+
else localStorage.setItem("datalynk-user", JSON.stringify(u));
|
|
1636
1944
|
});
|
|
1637
1945
|
}
|
|
1946
|
+
/** Offline login/encryption is active for this app */
|
|
1947
|
+
get vault() {
|
|
1948
|
+
var _a;
|
|
1949
|
+
return ((_a = this.api.vault) == null ? void 0 : _a.enabled) ? this.api.vault : void 0;
|
|
1950
|
+
}
|
|
1951
|
+
/** This session may use offline data (always true when offline login is opted out) */
|
|
1952
|
+
get offlineUnlocked() {
|
|
1953
|
+
return !this.vault || this.vault.unlocked;
|
|
1954
|
+
}
|
|
1638
1955
|
/** Current user */
|
|
1639
1956
|
get user() {
|
|
1640
1957
|
return this.user$.getValue();
|
|
@@ -1652,14 +1969,18 @@ class Auth {
|
|
|
1652
1969
|
*
|
|
1653
1970
|
* @return {Promise<User | null>}
|
|
1654
1971
|
*/
|
|
1655
|
-
async current(token = this.api.token) {
|
|
1656
|
-
var _a;
|
|
1972
|
+
async current(token = this.api.token, refresh = false) {
|
|
1973
|
+
var _a, _b, _c;
|
|
1657
1974
|
if (!token) return null;
|
|
1658
|
-
else if (token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
|
|
1659
|
-
else if (!this.api.online
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1975
|
+
else if (!refresh && token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
|
|
1976
|
+
else if (!this.api.online) {
|
|
1977
|
+
if (!this.offlineUnlocked || !((_b = this.api.options.offline) == null ? void 0 : _b.length)) return null;
|
|
1978
|
+
if (this.api.vault) return this.api.vault.getProfile();
|
|
1979
|
+
return null;
|
|
1980
|
+
}
|
|
1981
|
+
const user = await this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
|
|
1982
|
+
if (user && !user.guest && this.api.jwtPayload) (_c = this.vault) == null ? void 0 : _c.touch(this.api.jwtPayload.realm, this.api.jwtPayload.uid);
|
|
1983
|
+
return user;
|
|
1663
1984
|
}
|
|
1664
1985
|
/**
|
|
1665
1986
|
* Automatically handle sessions by checking localStorage & URL parameters for a token & prompting
|
|
@@ -1670,16 +1991,17 @@ class Auth {
|
|
|
1670
1991
|
* @return {Promise<void>} Login complete
|
|
1671
1992
|
*/
|
|
1672
1993
|
async handleLogin(spoke, options) {
|
|
1673
|
-
var _a, _b, _c, _d;
|
|
1994
|
+
var _a, _b, _c, _d, _e;
|
|
1674
1995
|
if (this.onlinePrompt) window.removeEventListener("online", this.onlinePrompt);
|
|
1675
1996
|
if ((_a = this.api.options.offline) == null ? void 0 : _a.length) window.removeEventListener("online", this.onlinePrompt = () => this.handleLogin(spoke, options));
|
|
1997
|
+
await ((_b = this.api.vault) == null ? void 0 : _b.ready);
|
|
1676
1998
|
const urlParams = new URLSearchParams(location.search);
|
|
1677
1999
|
const urlToken = urlParams.getAll("datalynkToken").filter(Boolean).pop();
|
|
1678
2000
|
if (urlToken) {
|
|
1679
2001
|
this.api.token = urlToken;
|
|
1680
2002
|
urlParams.delete("datalynkToken");
|
|
1681
2003
|
location.href = location.pathname + (urlParams.toString() ? "?" + urlParams.toString() : "") + location.hash;
|
|
1682
|
-
} else if (
|
|
2004
|
+
} else if (this.isOffline() && this.vault && !this.vault.unlocked) {
|
|
1683
2005
|
await this.loginPrompt(spoke, options).done;
|
|
1684
2006
|
return;
|
|
1685
2007
|
} else if (this.api.reauthenticationPending && !this.api.online && ((_c = this.api.jwtPayload) == null ? void 0 : _c.realm) == spoke) {
|
|
@@ -1688,6 +2010,8 @@ class Auth {
|
|
|
1688
2010
|
if (((_d = this.api.jwtPayload) == null ? void 0 : _d.realm) != spoke) {
|
|
1689
2011
|
this.api.token = null;
|
|
1690
2012
|
location.reload();
|
|
2013
|
+
} else if ((_e = this.vault) == null ? void 0 : _e.needsPassword) {
|
|
2014
|
+
await this.loginPrompt(spoke, options).done;
|
|
1691
2015
|
}
|
|
1692
2016
|
} else {
|
|
1693
2017
|
this.api.token = null;
|
|
@@ -1695,6 +2019,9 @@ class Auth {
|
|
|
1695
2019
|
location.reload();
|
|
1696
2020
|
}
|
|
1697
2021
|
}
|
|
2022
|
+
isOffline() {
|
|
2023
|
+
return !this.api.online || typeof navigator !== "undefined" && navigator.onLine === false;
|
|
2024
|
+
}
|
|
1698
2025
|
/**
|
|
1699
2026
|
* Check whether user has a token
|
|
1700
2027
|
*
|
|
@@ -1746,16 +2073,14 @@ class Auth {
|
|
|
1746
2073
|
* @returns {Promise<User>} User account
|
|
1747
2074
|
*/
|
|
1748
2075
|
async login(spoke, login, password, opts) {
|
|
1749
|
-
var _a
|
|
1750
|
-
if (
|
|
2076
|
+
var _a;
|
|
2077
|
+
if (this.vault && this.isOffline()) {
|
|
1751
2078
|
const identity2 = this.api.jwtPayload;
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const user =
|
|
1756
|
-
if (!
|
|
1757
|
-
throw new Error("Offline login is not available for this account on this device.");
|
|
1758
|
-
this.offlineUnlocked = true;
|
|
2079
|
+
if (!identity2 || identity2.realm !== spoke) throw new Error(offlineLoginMessages.none);
|
|
2080
|
+
const result = await this.vault.unlockOffline(spoke, login, identity2.uid, password.trim());
|
|
2081
|
+
if (result !== "ok") throw new Error(offlineLoginMessages[result]);
|
|
2082
|
+
const user = await this.vault.getProfile();
|
|
2083
|
+
if (!user) throw new Error(offlineLoginMessages.none);
|
|
1759
2084
|
this.user = user;
|
|
1760
2085
|
return user;
|
|
1761
2086
|
}
|
|
@@ -1776,8 +2101,8 @@ class Auth {
|
|
|
1776
2101
|
const data = await resp.json().catch(() => ({}));
|
|
1777
2102
|
if (!resp.ok || data["error"]) throw Object.assign(errorFromCode(resp.status, data.error) || {}, data);
|
|
1778
2103
|
this.api.token = data["token"];
|
|
1779
|
-
if (
|
|
1780
|
-
await
|
|
2104
|
+
if (this.vault && ((_a = this.api.jwtPayload) == null ? void 0 : _a.uid) != null)
|
|
2105
|
+
await this.vault.rememberLogin(spoke, login, this.api.jwtPayload.uid, password.trim()).catch((error) => console.error("Unable to enable offline login", error));
|
|
1781
2106
|
return data;
|
|
1782
2107
|
}
|
|
1783
2108
|
/**
|
|
@@ -1809,8 +2134,9 @@ class Auth {
|
|
|
1809
2134
|
* @return {Promise<{closed: string, new: string}>}
|
|
1810
2135
|
*/
|
|
1811
2136
|
logout(reload = true) {
|
|
2137
|
+
var _a;
|
|
1812
2138
|
localStorage.removeItem("datalynk-user");
|
|
1813
|
-
|
|
2139
|
+
void ((_a = this.api.vault) == null ? void 0 : _a.forget());
|
|
1814
2140
|
return this.api.request({ "$/auth/logout": {} }).then((resp) => {
|
|
1815
2141
|
if (reload) location.reload();
|
|
1816
2142
|
this.api.token = null;
|
|
@@ -2758,15 +3084,17 @@ const _Slice = class _Slice {
|
|
|
2758
3084
|
const lowest = resp.reduce((acc, r) => Math.min(acc, r.id), 0);
|
|
2759
3085
|
if (lowest < _Slice.nextId) _Slice.nextId = lowest;
|
|
2760
3086
|
}).finally(() => this.loaded = true);
|
|
2761
|
-
this.cache$.pipe(skip(1)).subscribe(
|
|
3087
|
+
this.cache$.pipe(skip(1)).subscribe((cache) => {
|
|
3088
|
+
const table = this.table;
|
|
3089
|
+
if (!table) return;
|
|
3090
|
+
const save = typeof table.replace == "function" ? table.replace(cache.map((c) => [c.id, c])) : table.clear().then(() => Promise.all(cache.map((c) => table.put(c.id, c))));
|
|
3091
|
+
save.catch((error) => console.warn("Unable to save offline slice cache", error));
|
|
3092
|
+
});
|
|
3093
|
+
this.api.token$.pipe(filter((token) => !!token), take(1)).subscribe(async () => {
|
|
2762
3094
|
var _a2;
|
|
2763
|
-
await ((_a2 = this.
|
|
2764
|
-
|
|
2765
|
-
var _a3;
|
|
2766
|
-
return (_a3 = this.table) == null ? void 0 : _a3.put(c.id, c);
|
|
2767
|
-
}));
|
|
3095
|
+
await ((_a2 = this.api.vault) == null ? void 0 : _a2.whenUnlocked());
|
|
3096
|
+
this.sync();
|
|
2768
3097
|
});
|
|
2769
|
-
this.sync();
|
|
2770
3098
|
this.api.online$.pipe(distinctUntilChanged()).subscribe(async (online) => {
|
|
2771
3099
|
if (!online) return;
|
|
2772
3100
|
if (this.api.expired) await lastValueFrom(this.api.auth.user$.pipe(skip(1), takeWhile((u) => !u || this.api.expired, true)));
|
|
@@ -3418,7 +3746,7 @@ class Superuser {
|
|
|
3418
3746
|
} });
|
|
3419
3747
|
}
|
|
3420
3748
|
}
|
|
3421
|
-
const version = "1.6.
|
|
3749
|
+
const version = "1.6.4";
|
|
3422
3750
|
class WebRtc {
|
|
3423
3751
|
constructor(api) {
|
|
3424
3752
|
__publicField(this, "ice");
|
|
@@ -4008,6 +4336,8 @@ const _Api = class _Api {
|
|
|
4008
4336
|
__publicField(this, "webrtc");
|
|
4009
4337
|
/** Offline database */
|
|
4010
4338
|
__publicField(this, "database");
|
|
4339
|
+
/** Offline login + offline cache encryption */
|
|
4340
|
+
__publicField(this, "vault");
|
|
4011
4341
|
/** Options */
|
|
4012
4342
|
__publicField(this, "options");
|
|
4013
4343
|
/** Created slices */
|
|
@@ -4033,7 +4363,7 @@ const _Api = class _Api {
|
|
|
4033
4363
|
__publicField(this, "online$", new BehaviorSubject(this.initialOnline));
|
|
4034
4364
|
/** API Session token */
|
|
4035
4365
|
__publicField(this, "token$", new BehaviorSubject(void 0));
|
|
4036
|
-
var _a, _b;
|
|
4366
|
+
var _a, _b, _c;
|
|
4037
4367
|
this.origin = origin;
|
|
4038
4368
|
this.url = `${new URL(origin).origin}/api/`;
|
|
4039
4369
|
const development = this.isDevelopmentEnvironment(origin);
|
|
@@ -4057,6 +4387,11 @@ const _Api = class _Api {
|
|
|
4057
4387
|
}
|
|
4058
4388
|
};
|
|
4059
4389
|
if (development) this.options.watchTokenExpiry = false;
|
|
4390
|
+
const offlineLogin = this.options.offlineLogin ?? true;
|
|
4391
|
+
this.vault = new OfflineVault(
|
|
4392
|
+
!!((_a = this.options.offline) == null ? void 0 : _a.length) && !!this.options.saveSession && offlineLogin !== false,
|
|
4393
|
+
typeof offlineLogin == "object" ? offlineLogin : {}
|
|
4394
|
+
);
|
|
4060
4395
|
if (this.options.saveSession && typeof localStorage != "undefined") {
|
|
4061
4396
|
this.token = localStorage.getItem(this.localStorageKey) || null;
|
|
4062
4397
|
this.token$.pipe(distinctUntilChanged()).subscribe((token) => {
|
|
@@ -4088,6 +4423,7 @@ const _Api = class _Api {
|
|
|
4088
4423
|
this.webrtc = new WebRtc(this);
|
|
4089
4424
|
if (typeof indexedDB != "undefined") {
|
|
4090
4425
|
this.database = new Database("datalynk", ["pending", ...this.options.offline || []]);
|
|
4426
|
+
this.database.cipher = this.vault;
|
|
4091
4427
|
if (this.options.replayOfflineQueue !== false) this.online$.subscribe((online) => {
|
|
4092
4428
|
if (online) void this.replayPendingQueue();
|
|
4093
4429
|
});
|
|
@@ -4097,10 +4433,10 @@ const _Api = class _Api {
|
|
|
4097
4433
|
window.addEventListener("offline", () => this.setConnectionStatus("offline"));
|
|
4098
4434
|
this.startHeartbeat();
|
|
4099
4435
|
}
|
|
4100
|
-
if ((
|
|
4436
|
+
if ((_b = this.options.offline) == null ? void 0 : _b.length) {
|
|
4101
4437
|
this.pwa.setup();
|
|
4102
4438
|
if (typeof indexedDB == "undefined") throw new Error("Cannot enable offline support, indexedDB is not available in this environment");
|
|
4103
|
-
(
|
|
4439
|
+
(_c = this.options.offline) == null ? void 0 : _c.forEach((id) => this.slice(id));
|
|
4104
4440
|
if (this.options.serviceWorker && typeof navigator["serviceWorker"] != "undefined") {
|
|
4105
4441
|
(async () => {
|
|
4106
4442
|
let initialized = false;
|
|
@@ -4412,7 +4748,7 @@ const _Api = class _Api {
|
|
|
4412
4748
|
} catch {
|
|
4413
4749
|
throw new UnexpectedApiResponseError(resp, body);
|
|
4414
4750
|
}
|
|
4415
|
-
if (resp.status === 401) this.markUnauthorized(token);
|
|
4751
|
+
if (resp.status === 401 && token) this.markUnauthorized(token);
|
|
4416
4752
|
if (!resp.ok || (data == null ? void 0 : data.error)) {
|
|
4417
4753
|
const error = Object.assign(errorFromCode(resp.status, data == null ? void 0 : data.error), data);
|
|
4418
4754
|
Object.defineProperty(error, "response", { value: resp, configurable: true });
|
|
@@ -4664,9 +5000,10 @@ const _Api = class _Api {
|
|
|
4664
5000
|
}));
|
|
4665
5001
|
}
|
|
4666
5002
|
async finishUnauthorizedLogout(preservePending = false) {
|
|
4667
|
-
var _a;
|
|
5003
|
+
var _a, _b, _c;
|
|
4668
5004
|
if (this.options.manageSession === false) return;
|
|
4669
|
-
|
|
5005
|
+
if (preservePending) await ((_a = this.vault) == null ? void 0 : _a.lock());
|
|
5006
|
+
else await ((_b = this.vault) == null ? void 0 : _b.forget());
|
|
4670
5007
|
if (typeof localStorage != "undefined") {
|
|
4671
5008
|
localStorage.removeItem(this.localStorageKey);
|
|
4672
5009
|
localStorage.removeItem("datalynk-user");
|
|
@@ -4687,7 +5024,7 @@ const _Api = class _Api {
|
|
|
4687
5024
|
console.error("Unable to clear Datalynk service worker cache", error);
|
|
4688
5025
|
}
|
|
4689
5026
|
}
|
|
4690
|
-
if (typeof navigator != "undefined" && ((
|
|
5027
|
+
if (typeof navigator != "undefined" && ((_c = navigator.serviceWorker) == null ? void 0 : _c.controller)) {
|
|
4691
5028
|
navigator.serviceWorker.controller.postMessage({ token: null });
|
|
4692
5029
|
}
|
|
4693
5030
|
if (typeof location != "undefined" && typeof location.reload == "function")
|
|
@@ -4828,7 +5165,10 @@ const _Api = class _Api {
|
|
|
4828
5165
|
if (!this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) break;
|
|
4829
5166
|
if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) break;
|
|
4830
5167
|
const request = await table.get(key);
|
|
4831
|
-
if (request == null)
|
|
5168
|
+
if (request == null) {
|
|
5169
|
+
await table.delete(key);
|
|
5170
|
+
continue;
|
|
5171
|
+
}
|
|
4832
5172
|
try {
|
|
4833
5173
|
await this.request(request);
|
|
4834
5174
|
await table.delete(key);
|