@saltcorn/mobile-app 1.7.0-alpha.0 → 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/package.json +1 -2
- package/share_extension_files/Info.plist +20 -2
- package/share_extension_files/ShareViewController.swift +117 -88
- package/src/helpers/api.js +67 -35
- package/src/helpers/auth.js +127 -116
- package/src/helpers/db_schema.js +6 -4
- package/src/helpers/file_system.js +3 -0
- package/src/helpers/offline_mode.js +9 -13
- package/src/index.js +5 -5
- package/src/init.js +41 -42
- package/src/routing/mocks/request.js +1 -1
- package/src/routing/routes/notifications.js +34 -10
- package/src/routing/routes/view.js +9 -3
- package/src/routing/utils.js +1 -1
- package/www/js/iframe_view_utils.js +65 -18
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@saltcorn/mobile-app",
|
|
3
3
|
"displayName": "Saltcorn mobile app",
|
|
4
|
-
"version": "1.7.0-alpha.
|
|
4
|
+
"version": "1.7.0-alpha.1",
|
|
5
5
|
"description": "Saltcorn mobile app for Android and iOS",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"scripts": {
|
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"universal-router": "9.2.1",
|
|
19
|
-
"axios": "0.32.0",
|
|
20
19
|
"jwt-decode": "4.0.0",
|
|
21
20
|
"i18next": "24.2.2",
|
|
22
21
|
"i18next-sprintf-postprocessor": "0.2.2",
|
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
3
|
<plist version="1.0">
|
|
4
4
|
<dict>
|
|
5
|
+
<key>CFBundleDevelopmentRegion</key>
|
|
6
|
+
<string>en</string>
|
|
7
|
+
<key>CFBundleDisplayName</key>
|
|
8
|
+
<string>$(PRODUCT_NAME)</string>
|
|
9
|
+
<key>CFBundleExecutable</key>
|
|
10
|
+
<string>$(EXECUTABLE_NAME)</string>
|
|
11
|
+
<key>CFBundleIdentifier</key>
|
|
12
|
+
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
|
13
|
+
<key>CFBundleInfoDictionaryVersion</key>
|
|
14
|
+
<string>6.0</string>
|
|
15
|
+
<key>CFBundleName</key>
|
|
16
|
+
<string>$(PRODUCT_NAME)</string>
|
|
17
|
+
<key>CFBundlePackageType</key>
|
|
18
|
+
<string>XPC!</string>
|
|
19
|
+
<key>CFBundleShortVersionString</key>
|
|
20
|
+
<string>$(MARKETING_VERSION)</string>
|
|
21
|
+
<key>CFBundleVersion</key>
|
|
22
|
+
<string>$(CURRENT_PROJECT_VERSION)</string>
|
|
5
23
|
<key>NSExtension</key>
|
|
6
24
|
<dict>
|
|
7
25
|
<key>NSExtensionAttributes</key>
|
|
@@ -24,8 +42,8 @@
|
|
|
24
42
|
<false/>
|
|
25
43
|
</dict>
|
|
26
44
|
</dict>
|
|
27
|
-
<key>
|
|
28
|
-
<string
|
|
45
|
+
<key>NSExtensionPrincipalClass</key>
|
|
46
|
+
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
|
29
47
|
<key>NSExtensionPointIdentifier</key>
|
|
30
48
|
<string>com.apple.share-services</string>
|
|
31
49
|
</dict>
|
|
@@ -10,89 +10,104 @@ import Social
|
|
|
10
10
|
import UIKit
|
|
11
11
|
|
|
12
12
|
class ShareItem {
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
public var title: String?
|
|
15
15
|
public var type: String?
|
|
16
16
|
public var url: String?
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
class ShareViewController: UIViewController {
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
private var shareItems: [ShareItem] = []
|
|
22
|
-
|
|
23
|
-
override public func viewDidAppear(_ animated: Bool) {
|
|
24
|
-
super.viewDidAppear(animated)
|
|
25
|
-
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
|
26
|
-
}
|
|
27
|
-
|
|
22
|
+
|
|
28
23
|
private func sendData() {
|
|
24
|
+
// URLComponents.url already percent-encodes queryItems values when
|
|
25
|
+
// building the final URL string - encoding them here too caused
|
|
26
|
+
// double-encoding (e.g. "text/plain" arrived as "text%2Fplain"),
|
|
27
|
+
// which broke type checks like isFileItem() on the JS side.
|
|
29
28
|
let queryItems = shareItems.map {
|
|
30
29
|
[
|
|
31
|
-
URLQueryItem(
|
|
32
|
-
name: "title",
|
|
33
|
-
value: $0.title?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
|
|
30
|
+
URLQueryItem(name: "title", value: $0.title ?? ""),
|
|
34
31
|
URLQueryItem(name: "description", value: ""),
|
|
35
|
-
URLQueryItem(
|
|
36
|
-
|
|
37
|
-
value: $0.type?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
|
|
38
|
-
URLQueryItem(
|
|
39
|
-
name: "url",
|
|
40
|
-
value: $0.url?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? ""),
|
|
32
|
+
URLQueryItem(name: "type", value: $0.type ?? ""),
|
|
33
|
+
URLQueryItem(name: "url", value: $0.url ?? ""),
|
|
41
34
|
]
|
|
42
35
|
}.flatMap({ $0 })
|
|
43
36
|
var urlComps = URLComponents(string: "scappscheme://")!
|
|
44
37
|
urlComps.queryItems = queryItems
|
|
45
38
|
openURL(urlComps.url!)
|
|
46
39
|
}
|
|
47
|
-
|
|
40
|
+
|
|
48
41
|
fileprivate func createSharedFileUrl(_ url: URL?) -> String {
|
|
49
|
-
let
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
42
|
+
guard let url = url else { return "" }
|
|
43
|
+
guard let containerURL = FileManager.default.containerURL(
|
|
44
|
+
forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID"
|
|
45
|
+
) else {
|
|
46
|
+
return ""
|
|
47
|
+
}
|
|
48
|
+
// appendingPathComponent handles escaping correctly on its own -
|
|
49
|
+
// hand-building a percent-encoded string here and then encoding it
|
|
50
|
+
// *again* as a URLQueryItem value in sendData() double-encoded it,
|
|
51
|
+
// so the path JS received didn't match any real file
|
|
52
|
+
let destinationURL = containerURL.appendingPathComponent(url.lastPathComponent)
|
|
53
|
+
|
|
54
|
+
// files vended by the Files app (iCloud, other apps' sandboxes) need
|
|
55
|
+
// this before they can actually be read - without it, Data(contentsOf:)
|
|
56
|
+
// fails and this used to silently return a path to a file that was
|
|
57
|
+
// never written
|
|
58
|
+
let didStartAccessing = url.startAccessingSecurityScopedResource()
|
|
59
|
+
defer {
|
|
60
|
+
if didStartAccessing { url.stopAccessingSecurityScopedResource() }
|
|
61
|
+
}
|
|
62
|
+
do {
|
|
63
|
+
try Data(contentsOf: url).write(to: destinationURL)
|
|
64
|
+
return destinationURL.absoluteString
|
|
65
|
+
} catch {
|
|
66
|
+
print("createSharedFileUrl failed: \(error)")
|
|
67
|
+
return ""
|
|
68
|
+
}
|
|
58
69
|
}
|
|
59
|
-
|
|
70
|
+
|
|
60
71
|
func saveScreenshot(_ image: UIImage, _ index: Int) -> String {
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
guard let containerURL = FileManager.default.containerURL(
|
|
73
|
+
forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_ID"
|
|
74
|
+
) else {
|
|
75
|
+
return ""
|
|
76
|
+
}
|
|
77
|
+
let destinationURL = containerURL.appendingPathComponent("screenshot_\(index).png")
|
|
67
78
|
do {
|
|
68
|
-
try image.pngData()?.write(to:
|
|
69
|
-
return
|
|
79
|
+
try image.pngData()?.write(to: destinationURL)
|
|
80
|
+
return destinationURL.absoluteString
|
|
70
81
|
} catch {
|
|
71
|
-
print(error.localizedDescription)
|
|
82
|
+
print("saveScreenshot failed: \(error.localizedDescription)")
|
|
72
83
|
return ""
|
|
73
84
|
}
|
|
74
85
|
}
|
|
75
|
-
|
|
86
|
+
|
|
76
87
|
fileprivate func handleTypeUrl(_ attachment: NSItemProvider)
|
|
77
88
|
async throws -> ShareItem
|
|
78
89
|
{
|
|
79
90
|
let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil)
|
|
80
|
-
let url = results as
|
|
91
|
+
guard let url = results as? URL else {
|
|
92
|
+
throw NSError(domain: "ShareViewController", code: 1, userInfo: [
|
|
93
|
+
NSLocalizedDescriptionKey: "handleTypeUrl: expected a URL, got \(type(of: results))"
|
|
94
|
+
])
|
|
95
|
+
}
|
|
81
96
|
let shareItem: ShareItem = ShareItem()
|
|
82
|
-
|
|
83
|
-
if url
|
|
84
|
-
shareItem.title = url
|
|
85
|
-
shareItem.type = "application/" + url
|
|
97
|
+
|
|
98
|
+
if url.isFileURL {
|
|
99
|
+
shareItem.title = url.lastPathComponent
|
|
100
|
+
shareItem.type = "application/" + url.pathExtension.lowercased()
|
|
86
101
|
shareItem.url = createSharedFileUrl(url)
|
|
87
102
|
} else {
|
|
88
|
-
shareItem.title = url
|
|
89
|
-
shareItem.url = url
|
|
103
|
+
shareItem.title = url.absoluteString
|
|
104
|
+
shareItem.url = url.absoluteString
|
|
90
105
|
shareItem.type = "text/plain"
|
|
91
106
|
}
|
|
92
|
-
|
|
107
|
+
|
|
93
108
|
return shareItem
|
|
94
109
|
}
|
|
95
|
-
|
|
110
|
+
|
|
96
111
|
fileprivate func handleTypeText(_ attachment: NSItemProvider)
|
|
97
112
|
async throws -> ShareItem
|
|
98
113
|
{
|
|
@@ -103,25 +118,29 @@ class ShareViewController: UIViewController {
|
|
|
103
118
|
shareItem.type = "text/plain"
|
|
104
119
|
return shareItem
|
|
105
120
|
}
|
|
106
|
-
|
|
121
|
+
|
|
107
122
|
fileprivate func handleTypeMovie(_ attachment: NSItemProvider)
|
|
108
123
|
async throws -> ShareItem
|
|
109
124
|
{
|
|
110
125
|
let results = try await attachment.loadItem(forTypeIdentifier: kUTTypeMovie as String, options: nil)
|
|
111
126
|
let shareItem: ShareItem = ShareItem()
|
|
112
|
-
|
|
113
|
-
let url = results as
|
|
114
|
-
|
|
115
|
-
|
|
127
|
+
|
|
128
|
+
guard let url = results as? URL else {
|
|
129
|
+
throw NSError(domain: "ShareViewController", code: 1, userInfo: [
|
|
130
|
+
NSLocalizedDescriptionKey: "handleTypeMovie: expected a URL, got \(type(of: results))"
|
|
131
|
+
])
|
|
132
|
+
}
|
|
133
|
+
shareItem.title = url.lastPathComponent
|
|
134
|
+
shareItem.type = "video/" + url.pathExtension.lowercased()
|
|
116
135
|
shareItem.url = createSharedFileUrl(url)
|
|
117
136
|
return shareItem
|
|
118
137
|
}
|
|
119
|
-
|
|
138
|
+
|
|
120
139
|
fileprivate func handleTypeImage(_ attachment: NSItemProvider, _ index: Int)
|
|
121
140
|
async throws -> ShareItem
|
|
122
141
|
{
|
|
123
142
|
let data = try await attachment.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil)
|
|
124
|
-
|
|
143
|
+
|
|
125
144
|
let shareItem: ShareItem = ShareItem()
|
|
126
145
|
switch data {
|
|
127
146
|
case let image as UIImage:
|
|
@@ -137,48 +156,58 @@ class ShareViewController: UIViewController {
|
|
|
137
156
|
}
|
|
138
157
|
return shareItem
|
|
139
158
|
}
|
|
140
|
-
|
|
159
|
+
|
|
141
160
|
override public func viewDidLoad() {
|
|
142
161
|
super.viewDidLoad()
|
|
143
|
-
|
|
162
|
+
|
|
144
163
|
shareItems.removeAll()
|
|
145
|
-
|
|
164
|
+
|
|
146
165
|
let extensionItem = extensionContext?.inputItems[0] as! NSExtensionItem
|
|
147
166
|
Task {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
167
|
+
do {
|
|
168
|
+
try await withThrowingTaskGroup(
|
|
169
|
+
of: ShareItem.self,
|
|
170
|
+
body: { taskGroup in
|
|
171
|
+
|
|
172
|
+
for (index, attachment) in extensionItem.attachments!.enumerated() {
|
|
173
|
+
if attachment.hasItemConformingToTypeIdentifier(kUTTypeURL as String) {
|
|
174
|
+
taskGroup.addTask {
|
|
175
|
+
return try await self.handleTypeUrl(attachment)
|
|
176
|
+
}
|
|
177
|
+
} else if attachment.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
|
|
178
|
+
taskGroup.addTask {
|
|
179
|
+
return try await self.handleTypeText(attachment)
|
|
180
|
+
}
|
|
181
|
+
} else if attachment.hasItemConformingToTypeIdentifier(kUTTypeMovie as String) {
|
|
182
|
+
taskGroup.addTask {
|
|
183
|
+
return try await self.handleTypeMovie(attachment)
|
|
184
|
+
}
|
|
185
|
+
} else if attachment.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
|
|
186
|
+
taskGroup.addTask {
|
|
187
|
+
return try await self.handleTypeImage(attachment, index)
|
|
188
|
+
}
|
|
168
189
|
}
|
|
169
190
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
|
|
191
|
+
|
|
192
|
+
for try await item in taskGroup {
|
|
193
|
+
self.shareItems.append(item)
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
} catch {
|
|
197
|
+
// a bad attachment (e.g. an unexpected type) still needs to
|
|
198
|
+
// complete the request below, or the share sheet hangs
|
|
199
|
+
print("viewDidLoad: task group failed: \(error)")
|
|
200
|
+
}
|
|
201
|
+
|
|
177
202
|
self.sendData()
|
|
178
|
-
|
|
203
|
+
// only complete (letting the OS tear this extension down) once
|
|
204
|
+
// sendData() has actually handed off to the host app - doing
|
|
205
|
+
// this in viewDidAppear instead raced the async work above and
|
|
206
|
+
// usually won, killing the extension before openURL() ran
|
|
207
|
+
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
|
|
179
208
|
}
|
|
180
209
|
}
|
|
181
|
-
|
|
210
|
+
|
|
182
211
|
@objc func openURL(_ url: URL) {
|
|
183
212
|
var responder: UIResponder? = self
|
|
184
213
|
while responder != nil {
|
|
@@ -189,5 +218,5 @@ class ShareViewController: UIViewController {
|
|
|
189
218
|
responder = responder?.next
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
|
-
|
|
193
|
-
}
|
|
221
|
+
|
|
222
|
+
}
|
package/src/helpers/api.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
/*global saltcorn, splashConfig*/
|
|
2
2
|
|
|
3
|
-
import axios from "axios";
|
|
4
|
-
import { jwtDecode } from "jwt-decode";
|
|
5
3
|
import { router } from "../routing/index";
|
|
6
4
|
import { replaceIframe, clearHistory } from "./navigation";
|
|
7
5
|
|
|
@@ -19,49 +17,83 @@ export async function apiCall({
|
|
|
19
17
|
? saltcorn.data.state.getState().mobileConfig
|
|
20
18
|
: splashConfig;
|
|
21
19
|
const serverPath = config.server_path;
|
|
22
|
-
|
|
20
|
+
let url = `${serverPath}${path}`;
|
|
21
|
+
if (params) {
|
|
22
|
+
const qs = new URLSearchParams(params).toString();
|
|
23
|
+
if (qs) url += `${url.includes("?") ? "&" : "?"}${qs}`;
|
|
24
|
+
}
|
|
23
25
|
const headers = {
|
|
24
26
|
"X-Requested-With": "XMLHttpRequest",
|
|
25
27
|
"X-Saltcorn-Client": "mobile-app",
|
|
26
28
|
...(additionalHeaders || {}),
|
|
27
29
|
};
|
|
28
30
|
if (config.tenantAppName) headers["X-Saltcorn-App"] = config.tenantAppName;
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
await saltcorn.data.db.deleteWhere("jwt_table");
|
|
37
|
-
clearHistory();
|
|
38
|
-
const page = await router.resolve({
|
|
39
|
-
pathname: "get/auth/login",
|
|
40
|
-
alerts: [
|
|
41
|
-
{ type: "warning", msg: "Your session has expired, please log in again." },
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
await replaceIframe(page.content);
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
// malformed token, let the request proceed and fail naturally
|
|
49
|
-
}
|
|
50
|
-
headers.Authorization = `jwt ${token}`;
|
|
31
|
+
// The session cookie is attached automatically by the WebView (credentials: "include").
|
|
32
|
+
// The CSRF token has to be sent explicitly, same as the web client does.
|
|
33
|
+
if (config.csrfToken) headers["CSRF-Token"] = config.csrfToken;
|
|
34
|
+
const fetchOptions = { method, headers, credentials: "include" };
|
|
35
|
+
if (body !== undefined) {
|
|
36
|
+
fetchOptions.body = JSON.stringify(body);
|
|
37
|
+
headers["Content-Type"] = "application/json";
|
|
51
38
|
}
|
|
39
|
+
const controller = timeout ? new AbortController() : null;
|
|
40
|
+
const timeoutId = controller
|
|
41
|
+
? setTimeout(() => controller.abort(), timeout)
|
|
42
|
+
: null;
|
|
43
|
+
if (controller) fetchOptions.signal = controller.signal;
|
|
52
44
|
try {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
45
|
+
const res = await fetch(url, fetchOptions);
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const error = new Error(`Request failed with status code ${res.status}`);
|
|
48
|
+
error.response = { status: res.status };
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
let data;
|
|
52
|
+
if (responseType === "blob") data = await res.blob();
|
|
53
|
+
else if (responseType === "text") data = await res.text();
|
|
54
|
+
else {
|
|
55
|
+
// A redirected error page still counts as a 200 here, so parse by
|
|
56
|
+
// hand to give a clear error instead of a cryptic JSON parse failure.
|
|
57
|
+
const text = await res.text();
|
|
58
|
+
try {
|
|
59
|
+
data = text ? JSON.parse(text) : undefined;
|
|
60
|
+
} catch {
|
|
61
|
+
const error = new Error(
|
|
62
|
+
`Expected JSON but got ${
|
|
63
|
+
res.headers.get("content-type") || "unknown content-type"
|
|
64
|
+
} (status ${res.status}, url ${res.url})`
|
|
65
|
+
);
|
|
66
|
+
error.response = { status: res.status };
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { data, status: res.status };
|
|
63
71
|
} catch (error) {
|
|
72
|
+
if (
|
|
73
|
+
error.response?.status === 401 &&
|
|
74
|
+
typeof saltcorn !== "undefined" &&
|
|
75
|
+
!path.startsWith("/auth/")
|
|
76
|
+
) {
|
|
77
|
+
const mobileConfig = saltcorn.data.state.getState().mobileConfig;
|
|
78
|
+
mobileConfig.csrfToken = undefined;
|
|
79
|
+
mobileConfig.hasSession = false;
|
|
80
|
+
await saltcorn.data.db.deleteWhere("session_table");
|
|
81
|
+
clearHistory();
|
|
82
|
+
const page = await router.resolve({
|
|
83
|
+
pathname: "get/auth/login",
|
|
84
|
+
alerts: [
|
|
85
|
+
{
|
|
86
|
+
type: "warning",
|
|
87
|
+
msg: "Your session has expired, please log in again.",
|
|
88
|
+
},
|
|
89
|
+
],
|
|
90
|
+
});
|
|
91
|
+
await replaceIframe(page.content);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
64
94
|
error.message = `Unable to call ${method} ${url}:\n${error.message}`;
|
|
65
95
|
throw error;
|
|
96
|
+
} finally {
|
|
97
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
66
98
|
}
|
|
67
99
|
}
|
package/src/helpers/auth.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/*global saltcorn*/
|
|
2
2
|
|
|
3
|
-
import { jwtDecode } from "jwt-decode";
|
|
4
3
|
import i18next from "i18next";
|
|
5
4
|
import { apiCall } from "./api";
|
|
6
5
|
import { router } from "../routing/index";
|
|
@@ -15,50 +14,57 @@ import {
|
|
|
15
14
|
} from "./common";
|
|
16
15
|
|
|
17
16
|
/**
|
|
18
|
-
*
|
|
17
|
+
* fetches a CSRF token and caches it in mobileConfig.csrfToken.
|
|
18
|
+
* Call again after every login/logout, since both reset it.
|
|
19
|
+
*/
|
|
20
|
+
export async function refreshCsrfToken() {
|
|
21
|
+
const response = await apiCall({ method: "GET", path: "/auth/csrf-token" });
|
|
22
|
+
const config = saltcorn.data.state.getState().mobileConfig;
|
|
23
|
+
config.csrfToken = response?.data?.csrfToken;
|
|
24
|
+
return config.csrfToken;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* internal helper for the normal login/signup
|
|
19
29
|
* @param {any} param0
|
|
20
30
|
* @returns
|
|
21
31
|
*/
|
|
22
|
-
async function loginRequest({ email, password, isSignup,
|
|
23
|
-
|
|
32
|
+
async function loginRequest({ email, password, isSignup, code }) {
|
|
33
|
+
await refreshCsrfToken();
|
|
34
|
+
const opts = code
|
|
24
35
|
? {
|
|
25
|
-
method: "
|
|
26
|
-
path: "/auth/
|
|
36
|
+
method: "POST",
|
|
37
|
+
path: "/auth/session-from-code",
|
|
38
|
+
body: { code },
|
|
27
39
|
}
|
|
28
40
|
: isSignup
|
|
29
41
|
? {
|
|
30
42
|
method: "POST",
|
|
31
43
|
path: "/auth/signup",
|
|
32
|
-
body: {
|
|
33
|
-
email,
|
|
34
|
-
password,
|
|
35
|
-
},
|
|
44
|
+
body: { email, password },
|
|
36
45
|
}
|
|
37
46
|
: {
|
|
38
47
|
method: "POST",
|
|
39
|
-
path: "/auth/login-with/
|
|
40
|
-
body: {
|
|
41
|
-
email,
|
|
42
|
-
password,
|
|
43
|
-
},
|
|
48
|
+
path: "/auth/login-with/session",
|
|
49
|
+
body: { email, password },
|
|
44
50
|
};
|
|
45
51
|
const response = await apiCall(opts);
|
|
46
52
|
return response.data;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
|
-
* internal helper to process a
|
|
51
|
-
*
|
|
56
|
+
* internal helper to process a successful session login response
|
|
57
|
+
* ({ success: true, user }) and persist it locally
|
|
52
58
|
*/
|
|
53
|
-
const
|
|
54
|
-
const token = jwtDecode(tokenStr);
|
|
55
|
-
const user = token.user;
|
|
59
|
+
const handleSessionLogin = async (user, config) => {
|
|
56
60
|
config.user = user;
|
|
57
61
|
config.isPublicUser = false;
|
|
58
62
|
config.isOfflineMode = false;
|
|
63
|
+
config.hasSession = true;
|
|
59
64
|
await insertUser(user);
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
// login regenerated the session, so the previously fetched token is stale
|
|
66
|
+
await refreshCsrfToken();
|
|
67
|
+
await persistSession(config);
|
|
62
68
|
i18next.changeLanguage(user.language);
|
|
63
69
|
};
|
|
64
70
|
|
|
@@ -101,22 +107,22 @@ const getEntryPoint = (config) => {
|
|
|
101
107
|
};
|
|
102
108
|
|
|
103
109
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
110
|
+
* Normal login/signup use email and password. An OAuth-provider login
|
|
111
|
+
* (e.g. google-auth's finishLogin) instead passes the short-lived code
|
|
112
|
+
* its deep-link callback was handed, which stands in for a session here.
|
|
106
113
|
* @param {*} param0
|
|
107
114
|
*/
|
|
108
115
|
export async function login({ email, password, isSignup, token }) {
|
|
109
|
-
const loginResult =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (typeof loginResult === "string") {
|
|
116
|
+
const loginResult = await loginRequest({
|
|
117
|
+
email,
|
|
118
|
+
password,
|
|
119
|
+
isSignup,
|
|
120
|
+
code: token,
|
|
121
|
+
});
|
|
122
|
+
if (loginResult?.success && loginResult?.user) {
|
|
117
123
|
const alerts = [];
|
|
118
124
|
const config = saltcorn.data.state.getState().mobileConfig;
|
|
119
|
-
await
|
|
125
|
+
await handleSessionLogin(loginResult.user, config);
|
|
120
126
|
if (config.allowOfflineMode) alerts.push(await initialSync(config));
|
|
121
127
|
await tryInitPush(config);
|
|
122
128
|
await tryInitBackgroundSync(config);
|
|
@@ -129,14 +135,31 @@ export async function login({ email, password, isSignup, token }) {
|
|
|
129
135
|
});
|
|
130
136
|
|
|
131
137
|
// open first page
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
try {
|
|
139
|
+
const entryPoint = getEntryPoint(config);
|
|
140
|
+
addRoute({ route: entryPoint, query: undefined });
|
|
141
|
+
const page = await router.resolve({
|
|
142
|
+
pathname: entryPoint,
|
|
143
|
+
fullWrap: true,
|
|
144
|
+
alerts,
|
|
145
|
+
});
|
|
146
|
+
if (page.content) await replaceIframe(page.content, page.isFile);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
// Logged in fine, but this user can't open the app's entry point
|
|
149
|
+
// (e.g. its role doesn't allow it) - say so instead of leaving
|
|
150
|
+
// them stuck on the login page with no explanation.
|
|
151
|
+
showToasts([
|
|
152
|
+
{
|
|
153
|
+
type: "error",
|
|
154
|
+
msg: i18next.t("Logged in as %s, but unable to open the app: %s", {
|
|
155
|
+
postProcess: "sprintf",
|
|
156
|
+
sprintf: [config.user.email, error.message || "unknown error"],
|
|
157
|
+
}),
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
} else if (loginResult?.error) {
|
|
162
|
+
showToasts([{ type: "error", msg: loginResult.error }]);
|
|
140
163
|
} else if (loginResult?.alerts) {
|
|
141
164
|
showToasts(loginResult?.alerts);
|
|
142
165
|
} else {
|
|
@@ -146,44 +169,39 @@ export async function login({ email, password, isSignup, token }) {
|
|
|
146
169
|
|
|
147
170
|
export async function publicLogin(entryPoint) {
|
|
148
171
|
try {
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
} else if (loginResult?.alerts) {
|
|
183
|
-
showToasts(loginResult?.alerts);
|
|
184
|
-
} else {
|
|
185
|
-
throw new Error("The login failed.");
|
|
186
|
-
}
|
|
172
|
+
const config = saltcorn.data.state.getState().mobileConfig;
|
|
173
|
+
// no login needed for public, but a CSRF token is still cached upfront.
|
|
174
|
+
await refreshCsrfToken();
|
|
175
|
+
config.user = {
|
|
176
|
+
role_id: 100,
|
|
177
|
+
email: "public",
|
|
178
|
+
language: "en",
|
|
179
|
+
};
|
|
180
|
+
config.isPublicUser = true;
|
|
181
|
+
config.hasSession = true;
|
|
182
|
+
await persistSession(config);
|
|
183
|
+
i18next.changeLanguage(config.user.language);
|
|
184
|
+
addRoute({
|
|
185
|
+
route: entryPoint,
|
|
186
|
+
query: undefined,
|
|
187
|
+
});
|
|
188
|
+
const page = await router.resolve({
|
|
189
|
+
pathname: entryPoint,
|
|
190
|
+
fullWrap: true,
|
|
191
|
+
alerts: [
|
|
192
|
+
{
|
|
193
|
+
type: "success",
|
|
194
|
+
msg: i18next.t("Welcome to %s!", {
|
|
195
|
+
postProcess: "sprintf",
|
|
196
|
+
sprintf: [
|
|
197
|
+
saltcorn.data.state.getState().getConfig("site_name") ||
|
|
198
|
+
"Saltcorn",
|
|
199
|
+
],
|
|
200
|
+
}),
|
|
201
|
+
},
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
if (page.content) await replaceIframe(page.content, page.isFile);
|
|
187
205
|
} catch (error) {
|
|
188
206
|
console.error(error);
|
|
189
207
|
showToasts([
|
|
@@ -203,9 +221,11 @@ export async function logout() {
|
|
|
203
221
|
await tryStopBackgroundSync();
|
|
204
222
|
const response = await apiCall({ method: "GET", path: "/auth/logout" });
|
|
205
223
|
if (response.data.success) {
|
|
206
|
-
await
|
|
224
|
+
await clearSession();
|
|
207
225
|
clearHistory();
|
|
208
|
-
config.
|
|
226
|
+
config.csrfToken = undefined;
|
|
227
|
+
config.hasSession = false;
|
|
228
|
+
config.isPublicUser = false;
|
|
209
229
|
const page = await router.resolve({
|
|
210
230
|
pathname: "get/auth/login",
|
|
211
231
|
entryView: config.entry_point,
|
|
@@ -232,43 +252,34 @@ export async function insertUser({ id, email, role_id, language }) {
|
|
|
232
252
|
);
|
|
233
253
|
}
|
|
234
254
|
|
|
235
|
-
export async function
|
|
236
|
-
await saltcorn.data.db.deleteWhere("
|
|
255
|
+
export async function clearSession() {
|
|
256
|
+
await saltcorn.data.db.deleteWhere("session_table");
|
|
237
257
|
}
|
|
238
258
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
async function
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const config = saltcorn.data.state.getState().mobileConfig;
|
|
252
|
-
await setJwt(response.data);
|
|
253
|
-
config.jwt = response.data;
|
|
254
|
-
}
|
|
255
|
-
} catch (e) {
|
|
256
|
-
console.error("JWT renewal failed:", e.message);
|
|
257
|
-
}
|
|
259
|
+
/**
|
|
260
|
+
* persists the current mobileConfig session state (csrf token, user,
|
|
261
|
+
* public/authenticated flag) so the app can restore it at next boot
|
|
262
|
+
* without a network call (used e.g. when starting offline).
|
|
263
|
+
*/
|
|
264
|
+
export async function persistSession(config) {
|
|
265
|
+
await clearSession();
|
|
266
|
+
await saltcorn.data.db.insert("session_table", {
|
|
267
|
+
csrfToken: config.csrfToken,
|
|
268
|
+
userJson: config.isPublicUser ? null : JSON.stringify(config.user),
|
|
269
|
+
isPublicUser: !!config.isPublicUser,
|
|
270
|
+
});
|
|
258
271
|
}
|
|
259
272
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return true;
|
|
273
|
-
} else return false;
|
|
273
|
+
/**
|
|
274
|
+
* checks with the server whether the current session cookie is still valid,
|
|
275
|
+
* and refreshes the cached CSRF token (its secret lives in the session, so
|
|
276
|
+
* it's fine to keep reusing the same token as long as the session is alive).
|
|
277
|
+
*/
|
|
278
|
+
export async function checkSession() {
|
|
279
|
+
const response = await apiCall({
|
|
280
|
+
method: "GET",
|
|
281
|
+
path: "/auth/authenticated",
|
|
282
|
+
timeout: 10000,
|
|
283
|
+
});
|
|
284
|
+
return !!response?.data?.authenticated;
|
|
274
285
|
}
|
package/src/helpers/db_schema.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/*global saltcorn */
|
|
2
2
|
|
|
3
3
|
const historyFile = "update_history";
|
|
4
|
-
const
|
|
4
|
+
const sessionTableName = "session_table";
|
|
5
5
|
|
|
6
6
|
import { readFile, fileExists, writeJSON } from "./file_system";
|
|
7
7
|
import { Directory } from "@capacitor/filesystem";
|
|
@@ -206,8 +206,10 @@ export async function updateDb(tablesJSON) {
|
|
|
206
206
|
});
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
-
export async function
|
|
210
|
-
await saltcorn.data.db.query(`CREATE TABLE IF NOT EXISTS ${
|
|
211
|
-
|
|
209
|
+
export async function createSessionTable() {
|
|
210
|
+
await saltcorn.data.db.query(`CREATE TABLE IF NOT EXISTS ${sessionTableName} (
|
|
211
|
+
csrfToken VARCHAR(500),
|
|
212
|
+
userJson VARCHAR(2000),
|
|
213
|
+
isPublicUser BOOLEAN
|
|
212
214
|
)`);
|
|
213
215
|
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { Filesystem, Encoding, Directory } from "@capacitor/filesystem";
|
|
2
|
+
// side-effect only: registers the plugin so it works on the web platform too,
|
|
3
|
+
// same as Filesystem above (native platforms pick it up either way)
|
|
4
|
+
import "@capacitor/file-transfer";
|
|
2
5
|
|
|
3
6
|
export async function writeFile(name, directory, content) {
|
|
4
7
|
try {
|
|
@@ -59,11 +59,11 @@ const insertRemoteData = async (table, rows, syncTimestamp) => {
|
|
|
59
59
|
const ref = _sync_info_tbl_ref_,
|
|
60
60
|
last_modified = _sync_info_tbl_last_modified_;
|
|
61
61
|
await saltcorn.data.db.insert(tblName, rest, { replace: true });
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
62
|
+
// ref is inlined as a literal, not passed as a param, so numeric-looking
|
|
63
|
+
// refs like "19" don't get silently turned into "19.0" on iOS.
|
|
64
|
+
const { rows: infos } = await saltcorn.data.db.query(
|
|
65
|
+
`select 1 from "${saltcorn.data.db.sqlsanitize(tblName)}_sync_info"
|
|
66
|
+
where ref=${sqlRef(rest[pkName])}`
|
|
67
67
|
);
|
|
68
68
|
if (infos.length > 0) {
|
|
69
69
|
await saltcorn.data.db.query(
|
|
@@ -72,14 +72,10 @@ const insertRemoteData = async (table, rows, syncTimestamp) => {
|
|
|
72
72
|
where ref=${sqlRef(rest[pkName])}`
|
|
73
73
|
);
|
|
74
74
|
} else {
|
|
75
|
-
await saltcorn.data.db.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
last_modified: syncTimestamp,
|
|
80
|
-
deleted: false,
|
|
81
|
-
modified_local: false,
|
|
82
|
-
}
|
|
75
|
+
await saltcorn.data.db.query(
|
|
76
|
+
`insert into "${saltcorn.data.db.sqlsanitize(tblName)}_sync_info"
|
|
77
|
+
(ref, last_modified, deleted, modified_local)
|
|
78
|
+
values (${sqlRef(ref)}, ${syncTimestamp}, false, false)`
|
|
83
79
|
);
|
|
84
80
|
}
|
|
85
81
|
}
|
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
|
-
|
|
14
|
+
createSessionTable,
|
|
15
15
|
} from "./helpers/db_schema.js";
|
|
16
|
-
import { publicLogin,
|
|
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
|
|
156
|
-
const rows = await saltcorn.data.db.select("
|
|
157
|
-
return rows?.length > 0 ? rows[0]
|
|
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
|
|
161
|
-
if (!(await saltcorn.data.db.tableExists("
|
|
162
|
-
await
|
|
159
|
+
const initSession = async () => {
|
|
160
|
+
if (!(await saltcorn.data.db.tableExists("session_table"))) {
|
|
161
|
+
await createSessionTable();
|
|
163
162
|
} else {
|
|
164
|
-
const
|
|
165
|
-
if (
|
|
163
|
+
const row = await getSession();
|
|
164
|
+
if (row?.csrfToken) {
|
|
166
165
|
const state = saltcorn.data.state.getState();
|
|
167
|
-
state.mobileConfig.
|
|
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?.
|
|
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 && !
|
|
230
|
-
await
|
|
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("
|
|
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
|
|
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
|
|
457
|
+
const hasSession = !!state.mobileConfig.hasSession;
|
|
458
|
+
const isPublicUser = !!state.mobileConfig.isPublicUser;
|
|
459
459
|
const alerts = [];
|
|
460
|
-
if (
|
|
461
|
-
|
|
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 (
|
|
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.
|
|
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 {
|
|
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
|
|
33
|
-
content = buildErrMsg("Not authenticated");
|
|
36
|
+
else if (!(await checkSession())) content = buildErrMsg("Not authenticated");
|
|
34
37
|
else {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
body
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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.
|
|
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.
|
|
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)}${
|
|
168
|
+
path: `/view/${encodeURIComponent(viewname)}${
|
|
169
|
+
context.query ? `?${context.query}` : ""
|
|
170
|
+
}`,
|
|
165
171
|
});
|
|
166
172
|
const data = response.data;
|
|
167
173
|
contents0 = data;
|
package/src/routing/utils.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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");
|