@saltcorn/mobile-app 1.6.3-beta.1 → 1.7.0-alpha.1

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/src/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { init } from "./init";
1
+ import { init, postShare } from "./init";
2
2
  import * as api from "./helpers/api";
3
3
  import * as auth from "./helpers/auth";
4
4
  import * as common from "./helpers/common";
@@ -12,7 +12,7 @@ import { router } from "./routing/index";
12
12
  let notifications = undefined;
13
13
  try {
14
14
  notifications = require("./helpers/notifications");
15
- console.log("Notifications module available");
15
+ console.log("Notifications module available");
16
16
  } catch (err) {
17
17
  console.log("Notifications module not available");
18
18
  }
@@ -21,9 +21,8 @@ try {
21
21
  let backgroundSync = undefined;
22
22
  try {
23
23
  backgroundSync = require("./helpers/background_sync");
24
- console.log("Background sync module available");
25
- }
26
- catch (err) {
24
+ console.log("Background sync module available");
25
+ } catch (err) {
27
26
  console.log("Background sync module not available");
28
27
  }
29
28
 
@@ -38,6 +37,7 @@ context.keys().forEach((key) => {
38
37
 
39
38
  export const mobileApp = {
40
39
  init,
40
+ postShare,
41
41
  api,
42
42
  auth,
43
43
  common,
package/src/init.js CHANGED
@@ -11,9 +11,9 @@ import {
11
11
  createSyncInfoTables,
12
12
  dbUpdateNeeded,
13
13
  updateDb,
14
- createJwtTable,
14
+ createSessionTable,
15
15
  } from "./helpers/db_schema.js";
16
- import { publicLogin, checkJWT } from "./helpers/auth.js";
16
+ import { publicLogin, checkSession } from "./helpers/auth.js";
17
17
  import { router } from "./routing/index.js";
18
18
  import {
19
19
  replaceIframe,
@@ -30,7 +30,6 @@ import { readJSONCordova, readTextCordova } from "./helpers/file_system.js";
30
30
 
31
31
  import i18next from "i18next";
32
32
  import i18nextSprintfPostProcessor from "i18next-sprintf-postprocessor";
33
- import { jwtDecode } from "jwt-decode";
34
33
 
35
34
  import { Network } from "@capacitor/network";
36
35
  import { App } from "@capacitor/app";
@@ -152,19 +151,34 @@ const handlePluginHeaders = async (plugins) => {
152
151
  }
153
152
  };
154
153
 
155
- const getJwt = async () => {
156
- const rows = await saltcorn.data.db.select("jwt_table");
157
- return rows?.length > 0 ? rows[0].jwt : null;
154
+ const getSession = async () => {
155
+ const rows = await saltcorn.data.db.select("session_table");
156
+ return rows?.length > 0 ? rows[0] : null;
158
157
  };
159
158
 
160
- const initJwt = async () => {
161
- if (!(await saltcorn.data.db.tableExists("jwt_table"))) {
162
- await createJwtTable();
159
+ const initSession = async () => {
160
+ if (!(await saltcorn.data.db.tableExists("session_table"))) {
161
+ await createSessionTable();
163
162
  } else {
164
- const jwt = await getJwt();
165
- if (jwt) {
163
+ const row = await getSession();
164
+ if (row?.csrfToken) {
166
165
  const state = saltcorn.data.state.getState();
167
- state.mobileConfig.jwt = jwt;
166
+ state.mobileConfig.csrfToken = row.csrfToken;
167
+ state.mobileConfig.isPublicUser = !!row.isPublicUser;
168
+ state.mobileConfig.hasSession = true;
169
+ if (row.isPublicUser) {
170
+ state.mobileConfig.user = {
171
+ role_id: 100,
172
+ email: "public",
173
+ language: "en",
174
+ };
175
+ } else if (row.userJson) {
176
+ try {
177
+ state.mobileConfig.user = JSON.parse(row.userJson);
178
+ } catch {
179
+ // corrupted local row, fall back to a network check
180
+ }
181
+ }
168
182
  }
169
183
  }
170
184
  };
@@ -210,7 +224,7 @@ const showErrorPage = async (error) => {
210
224
  const onResume = async () => {
211
225
  if (typeof saltcorn === "undefined") return;
212
226
  const mobileConfig = saltcorn.data.state.getState().mobileConfig;
213
- if (mobileConfig?.jwt) {
227
+ if (mobileConfig?.hasSession) {
214
228
  const netStatus = await Network.getStatus();
215
229
  mobileConfig.networkState = netStatus.connectionType;
216
230
  if (
@@ -226,8 +240,8 @@ const onResume = async () => {
226
240
  await showErrorPage(error);
227
241
  }
228
242
  } else if (mobileConfig.networkState !== "none") {
229
- if (!mobileConfig.isOfflineMode && !isPublicJwt(mobileConfig.jwt)) {
230
- await checkJWT(mobileConfig.jwt);
243
+ if (!mobileConfig.isOfflineMode && !mobileConfig.isPublicUser) {
244
+ await checkSession();
231
245
  }
232
246
  if (mobileConfig.allowOfflineMode && mobileConfig.syncOnAppResume) {
233
247
  try {
@@ -240,21 +254,6 @@ const onResume = async () => {
240
254
  }
241
255
  };
242
256
 
243
- const isPublicJwt = (jwt) => {
244
- try {
245
- if (!jwt) return false;
246
- const decoded = jwtDecode(jwt);
247
- return decoded.sub === "public";
248
- } catch (error) {
249
- console.log(
250
- `Unable to inspect '${jwt}': ${
251
- error.message ? error.message : "Unknown error"
252
- }`
253
- );
254
- return false;
255
- }
256
- };
257
-
258
257
  const isPublicEntryPoint = async (entryPoint) => {
259
258
  if (!entryPoint) return false;
260
259
  try {
@@ -309,7 +308,7 @@ const notEmpty = (shareData) => {
309
308
  }
310
309
  };
311
310
 
312
- const postShare = async (shareData) => {
311
+ export const postShare = async (shareData) => {
313
312
  const page = await router.resolve({
314
313
  pathname: "post/notifications/share",
315
314
  shareData,
@@ -390,7 +389,7 @@ export async function init(mobileConfig) {
390
389
  try {
391
390
  const url = event.url;
392
391
  if (url.startsWith("mobileapp://auth/callback")) {
393
- const token = new URL(url).searchParams.get("token");
392
+ const token = new URL(url).searchParams.get("code");
394
393
  const method = new URL(url).searchParams.get("method");
395
394
  const methods = saltcorn.data.state.getState().auth_methods;
396
395
  if (!methods[method])
@@ -429,7 +428,7 @@ export async function init(mobileConfig) {
429
428
  await updateDb(tablesJSON);
430
429
  }
431
430
  await createSyncInfoTables(mobileConfig.synchedTables);
432
- await initJwt();
431
+ await initSession();
433
432
  await state.refresh_tables();
434
433
  await state.refresh_views();
435
434
  await state.refresh_pages();
@@ -455,14 +454,16 @@ export async function init(mobileConfig) {
455
454
  Network.addListener("networkStatusChange", networkChangeCallback);
456
455
 
457
456
  const networkDisabled = state.mobileConfig.networkState === "none";
458
- const jwt = state.mobileConfig.jwt;
457
+ const hasSession = !!state.mobileConfig.hasSession;
458
+ const isPublicUser = !!state.mobileConfig.isPublicUser;
459
459
  const alerts = [];
460
- if ((networkDisabled && jwt) || (await checkJWT(jwt))) {
461
- // already logged in, continue
460
+ if (
461
+ hasSession &&
462
+ !isPublicUser &&
463
+ (networkDisabled || (await checkSession()))
464
+ ) {
465
+ // already logged in, continue - mobileConfig.user was restored by initSession()
462
466
  const mobileConfig = state.mobileConfig;
463
- const decodedJwt = jwtDecode(mobileConfig.jwt);
464
- mobileConfig.user = decodedJwt.user;
465
- mobileConfig.isPublicUser = false;
466
467
  await i18next.changeLanguage(mobileConfig.user.language);
467
468
  if (mobileConfig.allowOfflineMode) {
468
469
  const { offlineUser } = (await getLastOfflineSession()) || {};
@@ -536,11 +537,9 @@ export async function init(mobileConfig) {
536
537
  });
537
538
  }
538
539
  if (page.content) await replaceIframe(page.content, page.isFile);
539
- } else if (isPublicJwt(jwt)) {
540
+ } else if (hasSession && isPublicUser) {
540
541
  // already logged in as public
541
542
  const config = state.mobileConfig;
542
- config.user = { role_id: 100, email: "public", language: "en" };
543
- config.isPublicUser = true;
544
543
  i18next.changeLanguage(config.user.language);
545
544
  const entryPoint = getEntryPoint(100, state, state.mobileConfig);
546
545
  if (!entryPoint) await showLogin(alerts);
@@ -29,7 +29,7 @@ export function MobileRequest({
29
29
  }),
30
30
  isAuthenticated: () => {
31
31
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
32
- return mobileCfg && mobileCfg.jwt && !mobileCfg.isPublicUser;
32
+ return mobileCfg && mobileCfg.hasSession && !mobileCfg.isPublicUser;
33
33
  },
34
34
  getLocale: () => {
35
35
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
@@ -3,7 +3,7 @@
3
3
  import { wrapContents } from "../utils";
4
4
  import { MobileRequest } from "../mocks/request";
5
5
 
6
- import { checkJWT } from "../../helpers/auth.js";
6
+ import { checkSession } from "../../helpers/auth.js";
7
7
  import { apiCall } from "../../helpers/api";
8
8
 
9
9
  const buildErrMsg = (msg) => {
@@ -24,21 +24,45 @@ const buildSuccessMsg = (msg) => {
24
24
  );
25
25
  };
26
26
 
27
+ // files have a real type (e.g. image/png); text/link shares are "text/plain"
28
+ const isFileItem = (item) =>
29
+ item?.type && item.type !== "text/plain" && item.url;
30
+
27
31
  export const postShare = async (context) => {
28
32
  let content = "";
29
33
  const mobileCfg = saltcorn.data.state.getState().mobileConfig;
30
34
  if (mobileCfg.networkState === "none")
31
35
  content = buildErrMsg("No network connection");
32
- else if (!(await checkJWT(mobileCfg.jwt)))
33
- content = buildErrMsg("Not authenticated");
36
+ else if (!(await checkSession())) content = buildErrMsg("Not authenticated");
34
37
  else {
35
- const response = await apiCall({
36
- method: "POST",
37
- path: "/notifications/share-handler",
38
- body: context.shareData,
39
- });
40
- if (response.error) content = buildErrMsg(response.error);
41
- else content = buildSuccessMsg("Shared successfully");
38
+ try {
39
+ const { title, description, type, url, additionalItems } =
40
+ context.shareData || {};
41
+ let body = context.shareData;
42
+ if (isFileItem({ type, url })) {
43
+ const items = [{ title, type, url }, ...(additionalItems || [])];
44
+ // title is the real on-disk filename, location is where to find it
45
+ const files = [];
46
+ for (const item of items) {
47
+ const uploaded = await saltcorn.data.models.File.uploadFromPath(
48
+ item.url,
49
+ item.title || "shared-file",
50
+ item.type
51
+ );
52
+ files.push({ title: uploaded.filename, location: uploaded.location });
53
+ }
54
+ body = { description, files };
55
+ }
56
+ const response = await apiCall({
57
+ method: "POST",
58
+ path: "/notifications/share-handler",
59
+ body,
60
+ });
61
+ if (response.error) content = buildErrMsg(response.error);
62
+ else content = buildSuccessMsg("Shared successfully");
63
+ } catch (error) {
64
+ content = buildErrMsg(error.message || "Error sharing");
65
+ }
42
66
  }
43
67
  return await wrapContents(
44
68
  saltcorn.markup.tags.div(
@@ -39,7 +39,7 @@ export const postView = async (context) => {
39
39
  const mobileCfg = state.mobileConfig;
40
40
  if (
41
41
  mobileCfg.user.role_id > view.min_role &&
42
- !(await view.authorise_post({ body, req, ...view }))
42
+ !(await view.authorize(mobileCfg.user, { action: "post", req, body }))
43
43
  ) {
44
44
  throw new saltcorn.data.utils.NotAuthorized(req.__("Not authorized"));
45
45
  }
@@ -150,7 +150,11 @@ export const getView = async (context) => {
150
150
  if (!view) throw new Error(req.__("No such view: %s", viewname));
151
151
  if (
152
152
  state.mobileConfig.user.role_id > view.min_role &&
153
- !(await view.authorise_get({ query, req, ...view }))
153
+ !(await view.authorize(state.mobileConfig.user, {
154
+ action: "get",
155
+ req,
156
+ state: query,
157
+ }))
154
158
  ) {
155
159
  const additionalInfos = `: your role: ${state.mobileConfig.user.role_id}, view min_role: ${view.min_role}`;
156
160
  throw new saltcorn.data.utils.NotAuthorized(
@@ -161,7 +165,9 @@ export const getView = async (context) => {
161
165
  if (!view.renderLocally()) {
162
166
  const response = await apiCall({
163
167
  method: "GET",
164
- path: `/view/${encodeURIComponent(viewname)}${context.query ? `?${context.query}` : ""}`,
168
+ path: `/view/${encodeURIComponent(viewname)}${
169
+ context.query ? `?${context.query}` : ""
170
+ }`,
165
171
  });
166
172
  const data = response.data;
167
173
  contents0 = data;
@@ -24,7 +24,7 @@ export const getHeaders = () => {
24
24
  from_cfg.push({ style: state.getConfig("page_custom_css", "") });
25
25
  if (state.getConfig("page_custom_html", ""))
26
26
  from_cfg.push({ headerTag: state.getConfig("page_custom_html", "") });
27
- return [...stdHeaders, ...config.pluginHeaders, ...from_cfg];
27
+ return [...stdHeaders, ...(config.pluginHeaders || []), ...from_cfg];
28
28
  };
29
29
 
30
30
  export const parseQuery = (queryStr) => {
@@ -1,6 +1,21 @@
1
1
  /*eslint-env browser*/
2
2
  /*global $, KTDrawer, reload_embedded_view, submitWithEmptyAction, is_paging_param, bootstrap, common_done, unique_field_from_rows, inline_submit_success, get_current_state_url, initialize_page, reset_spinners */
3
3
 
4
+ // Tapping a javascript: link silently does nothing on iOS, so we run the
5
+ // code ourselves instead of relying on the link to do it.
6
+ document.addEventListener("click", function (event) {
7
+ const link =
8
+ event.target.closest && event.target.closest("a[href^='javascript:']");
9
+ if (!link) return;
10
+ event.preventDefault();
11
+ const code = link.getAttribute("href").slice("javascript:".length);
12
+ try {
13
+ (0, eval)(code);
14
+ } catch (error) {
15
+ console.error("Error executing javascript: link:", error);
16
+ }
17
+ });
18
+
4
19
  function combineFormAndQuery(form, query) {
5
20
  let paramsList = [];
6
21
  const formData = new FormData(form[0]);
@@ -87,7 +102,10 @@ async function formSubmit(e, urlSuffix, viewname, noSubmitCb, matchingState) {
87
102
  const urlParams = new URLSearchParams();
88
103
  const data = matchingState ? {} : null;
89
104
  for (const entry of new FormData(e).entries()) {
90
- if (entry[1] instanceof File) files[entry[0]] = entry[1];
105
+ // cordova-plugin-file replaces window.File with its own class, so a
106
+ // real picked file no longer passes "instanceof File" - Blob still
107
+ // works, since every File is also a Blob.
108
+ if (entry[1] instanceof Blob) files[entry[0]] = entry[1];
91
109
  else {
92
110
  // is there a hidden input with a filename?
93
111
  const domEl = $(e).find(
@@ -431,22 +449,50 @@ async function pjax_to(href, query, e) {
431
449
  await parent.saltcorn.mobileApp.navigation.handleRoute(safeHref, query);
432
450
  else
433
451
  try {
434
- const headers = {
435
- pjaxpageload: "true",
436
- };
437
- if (localizer.length) headers.localizedstate = "true";
438
- const result = await parent.saltcorn.mobileApp.api.apiCall({
439
- path: path,
440
- method: "GET",
441
- additionalHeaders: headers,
442
- });
452
+ const { isOfflineMode } =
453
+ parent.saltcorn.data.state.getState().mobileConfig;
454
+ let content;
455
+ if (isOfflineMode) {
456
+ const page = await parent.saltcorn.mobileApp.navigation.router.resolve(
457
+ {
458
+ pathname: `get${safeHref}`,
459
+ query: query,
460
+ }
461
+ );
462
+ if (page.redirect) {
463
+ const { path: redirectPath, query: redirectQuery } =
464
+ parent.saltcorn.mobileApp.navigation.splitPathQuery(
465
+ page.redirect
466
+ );
467
+ await parent.saltcorn.mobileApp.navigation.handleRoute(
468
+ redirectPath.startsWith("/") && redirectPath.length > 1
469
+ ? `get${redirectPath}`
470
+ : redirectPath,
471
+ redirectQuery
472
+ );
473
+ return;
474
+ }
475
+ content = typeof page === "string" ? page : page.content;
476
+ } else {
477
+ const headers = {
478
+ pjaxpageload: "true",
479
+ };
480
+ if (localizer.length) headers.localizedstate = "true";
481
+ const result = await parent.saltcorn.mobileApp.api.apiCall({
482
+ path: path,
483
+ method: "GET",
484
+ additionalHeaders: headers,
485
+ responseType: "text",
486
+ });
487
+ content = result.data;
488
+ }
443
489
  if (!inModal && !localizer.length) {
444
490
  // not sure for mobile
445
491
  // window.history.pushState({ url: href }, "", href);
446
492
  }
447
493
  if (inModal && !localizer.length)
448
494
  $(".sc-modal-linkout").attr("href", path);
449
- $dest.html(result.data);
495
+ $dest.html(content);
450
496
  if (localizer.length) localizer.attr("data-sc-local-state", path);
451
497
  initialize_page();
452
498
  } catch (error) {
@@ -675,17 +721,16 @@ async function make_unique_field(
675
721
  }
676
722
  }
677
723
 
678
- function openFile(fileId) {
724
+ async function openFile(fileId) {
679
725
  const config = parent.saltcorn.data.state.getState().mobileConfig;
680
726
  const serverPath = config.server_path;
681
- const token = config.jwt;
682
- const url = `${serverPath}/files/serve/${encodeURIComponent(
683
- fileId
684
- )}?jwt=${token}`;
727
+ const url = `${serverPath}/files/serve/${encodeURIComponent(fileId)}`;
685
728
  parent.cordova.InAppBrowser.open(
686
729
  url,
687
730
  "_blank",
688
- "clearcache=yes,clearsessioncache=yes,location=no,toolbar=yes,toolbarposition=top"
731
+ // No clearcache - it clears cookies app-wide (Android's cookie store
732
+ // isn't scoped per tab), which would log the main app out too.
733
+ "location=no,toolbar=yes,toolbarposition=top"
689
734
  );
690
735
  }
691
736
 
@@ -695,7 +740,9 @@ function openInAppBrowser(url, domId) {
695
740
  const ref = parent.cordova.InAppBrowser.open(
696
741
  url,
697
742
  "_blank",
698
- "clearcache=yes,clearsessioncache=yes,location=no,toolbar=yes,toolbarposition=top"
743
+ // No clearcache - it clears cookies app-wide (Android's cookie store
744
+ // isn't scoped per tab), which would log the main app out too.
745
+ "location=no,toolbar=yes,toolbarposition=top"
699
746
  );
700
747
  ref.addEventListener("exit", function () {
701
748
  $(`#${domId}`).find(".spinner-border").addClass("d-none");
@@ -1,16 +0,0 @@
1
- require 'xcodeproj'
2
-
3
- project_path = 'ios/App/App.xcodeproj'
4
- target_name = 'App'
5
- entitlements_relative_path = 'App/App.entitlements'
6
-
7
- project = Xcodeproj::Project.open(project_path)
8
- target = project.targets.find { |t| t.name == target_name }
9
- abort("Target '#{target_name}' not found") unless target
10
-
11
- target.build_configurations.each do |config|
12
- config.build_settings['CODE_SIGN_ENTITLEMENTS'] = entitlements_relative_path
13
- end
14
-
15
- project.save
16
- puts "Entitlements configured successfully for target '#{target_name}'"
@@ -1,91 +0,0 @@
1
- import { BackgroundFetch } from "@transistorsoft/capacitor-background-fetch";
2
- import { sync } from "./offline_mode.js";
3
-
4
- let isConfigured = false;
5
-
6
- /**
7
- * Init the periodic background sync with a min interval.
8
- * This runs the sync even if the app is in background or was swipe closed.
9
- * If no internet connection is available it fails silently.
10
- * @param {number} interval min time interval in minutes. The system decides when to actually do it
11
- * @returns {Promise<boolean>} True if configuration was successful, false otherwise.
12
- */
13
- export async function startPeriodicBackgroundSync(interval = 15) {
14
- if (isConfigured) {
15
- console.log("Background sync is already configured. Skipping.");
16
- return true;
17
- }
18
-
19
- console.log("Configuring background sync with interval (minutes):", interval);
20
-
21
- const status = await BackgroundFetch.configure(
22
- {
23
- minimumFetchInterval: interval,
24
- },
25
- async (taskId) => {
26
- console.log(
27
- "Starting background sync:",
28
- taskId,
29
- new Date().toISOString()
30
- );
31
- await sync(true);
32
- console.log(
33
- "Background sync finished:",
34
- taskId,
35
- new Date().toISOString()
36
- );
37
- BackgroundFetch.finish(taskId);
38
- },
39
- async (taskId) => {
40
- console.log("[BackgroundFetch] TIMEOUT:", taskId);
41
- BackgroundFetch.finish(taskId);
42
- }
43
- );
44
-
45
- if (status === BackgroundFetch.STATUS_AVAILABLE) {
46
- console.log("Background sync successfully configured.");
47
- isConfigured = true; // Set the flag only on success
48
- return true;
49
- } else {
50
- // Handle error statuses
51
- if (status === BackgroundFetch.STATUS_DENIED) {
52
- console.log(
53
- "The user explicitly disabled background behavior for this app or for the whole system."
54
- );
55
- } else if (status === BackgroundFetch.STATUS_RESTRICTED) {
56
- console.log(
57
- "Background updates are unavailable and the user cannot enable them again."
58
- );
59
- }
60
- isConfigured = false;
61
- return false;
62
- }
63
- }
64
-
65
- /**
66
- * Stops/Unregisters the periodic background sync.
67
- * This should be called when the user logs out.
68
- */
69
- export async function stopPeriodicBackgroundSync() {
70
- if (!isConfigured) {
71
- console.log("Background sync is not currently configured. Skipping stop.");
72
- return;
73
- }
74
-
75
- console.log("Stopping background sync");
76
- try {
77
- await BackgroundFetch.stop();
78
- console.log("Background sync successfully stopped.");
79
- isConfigured = false;
80
- } catch (error) {
81
- console.error("Error stopping background sync:", error);
82
- }
83
- }
84
-
85
- /**
86
- * Check if the background sync is currently configured/enabled.
87
- * @returns {boolean} True if the sync is configured, false otherwise.
88
- */
89
- export function isBackgroundSyncActive() {
90
- return isConfigured;
91
- }