@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/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
- add(value, key) {
258
- return this.tx((store) => store.add(value, key));
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
- put(key, value) {
261
- return this.tx((store) => store.put(value, key));
263
+ open(value) {
264
+ const cipher = this.database.cipher;
265
+ return cipher ? cipher.open(value) : Promise.resolve(value);
262
266
  }
263
- get(key) {
264
- return this.tx((store) => store.get(key), true);
267
+ async add(value, key) {
268
+ const sealed = await this.seal(value);
269
+ return this.tx((store) => store.add(sealed, key));
265
270
  }
266
- getAll() {
267
- return this.tx((store) => store.getAll(), true);
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 KEY = "datalynk-offline-login";
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
- async function hash(password, salt) {
1550
- const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
1551
- return Array.from(new Uint8Array(await crypto.subtle.deriveBits(
1552
- { name: "PBKDF2", salt: new Uint8Array(salt), iterations: ITERATIONS, hash: "SHA-256" },
1553
- key,
1554
- 256
1555
- )));
1556
- }
1557
- async function rememberOfflineLogin(realm, login, uid, password) {
1558
- const salt = crypto.getRandomValues(new Uint8Array(16));
1559
- const record = { realm, login: login.trim().toLowerCase(), uid, salt: Array.from(salt), hash: await hash(password, salt) };
1560
- localStorage.setItem(KEY, JSON.stringify(record));
1561
- }
1562
- async function verifyOfflineLogin(realm, login, uid, password) {
1563
- const saved = localStorage.getItem(KEY);
1564
- if (!saved) return false;
1565
- let record;
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
- function hasOfflineLogin(realm, uid) {
1579
- try {
1580
- const record = JSON.parse(localStorage.getItem(KEY) || "null");
1581
- return (record == null ? void 0 : record.realm) === realm && (record == null ? void 0 : record.uid) === uid;
1582
- } catch {
1583
- return false;
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
- const user = await this.current();
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 === "unauthorized") forgetOfflineLogin();
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
- if (this.api.online) localStorage.removeItem("datalynk-user");
1635
- } else localStorage.setItem("datalynk-user", JSON.stringify(u));
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 && this.offlineUnlocked && typeof localStorage != "undefined" && localStorage.getItem("datalynk-user"))
1660
- return JSON.parse(localStorage.getItem("datalynk-user"));
1661
- else if (!this.api.online) return null;
1662
- return this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
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 (!this.api.online && ((_b = this.api.options.offline) == null ? void 0 : _b.length) && !this.offlineUnlocked) {
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, _b, _c;
1750
- if (((_a = this.api.options.offline) == null ? void 0 : _a.length) && (!this.api.online || typeof navigator !== "undefined" && !navigator.onLine)) {
2076
+ var _a;
2077
+ if (this.vault && this.isOffline()) {
1751
2078
  const identity2 = this.api.jwtPayload;
1752
- const saved = localStorage.getItem("datalynk-user");
1753
- if (!identity2 || identity2.realm !== spoke || !saved || !await verifyOfflineLogin(spoke, login, identity2.uid, password.trim()))
1754
- throw new Error("Offline login is not available for this account on this device.");
1755
- const user = JSON.parse(saved);
1756
- if (![user.login, user.email].some((value) => (value == null ? void 0 : value.toLowerCase()) === login.trim().toLowerCase()))
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 (((_b = this.api.options.offline) == null ? void 0 : _b.length) && this.api.options.saveSession && ((_c = this.api.jwtPayload) == null ? void 0 : _c.uid) != null)
1780
- await rememberOfflineLogin(spoke, login, this.api.jwtPayload.uid, password.trim());
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
- forgetOfflineLogin();
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(async (cache) => {
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.table) == null ? void 0 : _a2.clear());
2764
- await Promise.all(cache.map((c) => {
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.3";
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 ((_a = this.options.offline) == null ? void 0 : _a.length) {
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
- (_b = this.options.offline) == null ? void 0 : _b.forEach((id) => this.slice(id));
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
- forgetOfflineLogin();
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" && ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller)) {
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) continue;
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);