botschat 0.1.12 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +7 -1
- package/packages/api/src/do/connection-do.ts +90 -1
- package/packages/api/src/env.ts +2 -0
- package/packages/api/src/index.ts +4 -1
- package/packages/api/src/routes/auth.ts +39 -6
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +89 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-CCBhODDo.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/{index-CCFgKLX_.js → index-Civeg2lm.js} +1 -1
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/{index-Dx64BDkP.js → index-lVB82JKU.js} +1 -1
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/{web-DJQW-VLX.js → web-CUXjh_UA.js} +1 -1
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/src/App.tsx +42 -2
- package/packages/web/src/api.ts +10 -0
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +49 -9
- package/packages/web/src/firebase.ts +89 -2
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/main.tsx +2 -1
- package/packages/web/src/push.ts +205 -0
- package/scripts/dev.sh +139 -13
- package/scripts/mock-openclaw.mjs +382 -0
- package/packages/web/dist/assets/index-D8mBAwjS.js +0 -1516
- package/packages/web/dist/assets/index-E-nzPZl8.js +0 -2
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
import { useAppState } from "../store";
|
|
3
|
+
import { setToken, setRefreshToken } from "../api";
|
|
4
|
+
|
|
5
|
+
export function AccountSettings() {
|
|
6
|
+
const state = useAppState();
|
|
7
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
8
|
+
const [confirmText, setConfirmText] = useState("");
|
|
9
|
+
const [busy, setBusy] = useState(false);
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
|
|
12
|
+
const handleLogout = () => {
|
|
13
|
+
setToken(null);
|
|
14
|
+
setRefreshToken(null);
|
|
15
|
+
localStorage.clear();
|
|
16
|
+
window.location.reload();
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const handleDelete = async () => {
|
|
20
|
+
if (confirmText !== "DELETE") return;
|
|
21
|
+
setBusy(true);
|
|
22
|
+
setError(null);
|
|
23
|
+
try {
|
|
24
|
+
const token = localStorage.getItem("botschat_token");
|
|
25
|
+
const res = await fetch("/api/auth/account", {
|
|
26
|
+
method: "DELETE",
|
|
27
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
28
|
+
});
|
|
29
|
+
if (!res.ok) {
|
|
30
|
+
const data = await res.json().catch(() => ({ error: res.statusText }));
|
|
31
|
+
throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
32
|
+
}
|
|
33
|
+
setToken(null);
|
|
34
|
+
setRefreshToken(null);
|
|
35
|
+
localStorage.clear();
|
|
36
|
+
window.location.reload();
|
|
37
|
+
} catch (err) {
|
|
38
|
+
setError(err instanceof Error ? err.message : "Failed to delete account");
|
|
39
|
+
setBusy(false);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div className="space-y-6">
|
|
45
|
+
{/* Account Info */}
|
|
46
|
+
<div>
|
|
47
|
+
<h3 className="text-h3 font-bold mb-2" style={{ color: "var(--text-primary)" }}>
|
|
48
|
+
Account
|
|
49
|
+
</h3>
|
|
50
|
+
<div className="space-y-1.5">
|
|
51
|
+
<p className="text-body" style={{ color: "var(--text-secondary)" }}>
|
|
52
|
+
<span style={{ color: "var(--text-muted)" }}>Email: </span>
|
|
53
|
+
{state.user?.email ?? "—"}
|
|
54
|
+
</p>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{/* Logout */}
|
|
59
|
+
<div>
|
|
60
|
+
<button
|
|
61
|
+
onClick={handleLogout}
|
|
62
|
+
className="px-4 py-2 rounded-md text-caption font-bold"
|
|
63
|
+
style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
|
|
64
|
+
>
|
|
65
|
+
Sign Out
|
|
66
|
+
</button>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{/* Danger Zone */}
|
|
70
|
+
<div
|
|
71
|
+
className="p-4 rounded-md border"
|
|
72
|
+
style={{ borderColor: "var(--accent-red, #e53e3e)", background: "rgba(255, 0, 0, 0.04)" }}
|
|
73
|
+
>
|
|
74
|
+
<h4 className="text-caption font-bold mb-2" style={{ color: "var(--accent-red, #e53e3e)" }}>
|
|
75
|
+
Danger Zone
|
|
76
|
+
</h4>
|
|
77
|
+
<p className="text-caption mb-3" style={{ color: "var(--text-muted)" }}>
|
|
78
|
+
Permanently delete your account and all associated data (messages, channels,
|
|
79
|
+
automations, media). This action cannot be undone.
|
|
80
|
+
</p>
|
|
81
|
+
|
|
82
|
+
{!showConfirm ? (
|
|
83
|
+
<button
|
|
84
|
+
onClick={() => setShowConfirm(true)}
|
|
85
|
+
className="px-4 py-2 rounded-md text-caption font-bold"
|
|
86
|
+
style={{ background: "var(--accent-red, #e53e3e)", color: "#fff" }}
|
|
87
|
+
>
|
|
88
|
+
Delete Account
|
|
89
|
+
</button>
|
|
90
|
+
) : (
|
|
91
|
+
<div className="space-y-3">
|
|
92
|
+
<p className="text-caption font-bold" style={{ color: "var(--text-primary)" }}>
|
|
93
|
+
Type <code style={{ color: "var(--accent-red, #e53e3e)" }}>DELETE</code> to confirm:
|
|
94
|
+
</p>
|
|
95
|
+
<input
|
|
96
|
+
type="text"
|
|
97
|
+
value={confirmText}
|
|
98
|
+
onChange={(e) => setConfirmText(e.target.value)}
|
|
99
|
+
className="w-full px-3 py-2 rounded border text-body"
|
|
100
|
+
style={{ background: "var(--bg-input, var(--bg-surface))", borderColor: "var(--border)", color: "var(--text-primary)" }}
|
|
101
|
+
placeholder="DELETE"
|
|
102
|
+
autoFocus
|
|
103
|
+
/>
|
|
104
|
+
{error && <p className="text-caption" style={{ color: "var(--accent-red, #e53e3e)" }}>{error}</p>}
|
|
105
|
+
<div className="flex gap-2">
|
|
106
|
+
<button
|
|
107
|
+
onClick={handleDelete}
|
|
108
|
+
disabled={confirmText !== "DELETE" || busy}
|
|
109
|
+
className="px-4 py-2 rounded-md text-caption font-bold"
|
|
110
|
+
style={{
|
|
111
|
+
background: "var(--accent-red, #e53e3e)",
|
|
112
|
+
color: "#fff",
|
|
113
|
+
opacity: confirmText !== "DELETE" || busy ? 0.5 : 1,
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
{busy ? "Deleting..." : "Permanently Delete"}
|
|
117
|
+
</button>
|
|
118
|
+
<button
|
|
119
|
+
onClick={() => { setShowConfirm(false); setConfirmText(""); setError(null); }}
|
|
120
|
+
className="px-4 py-2 rounded-md text-caption font-bold"
|
|
121
|
+
style={{ background: "var(--bg-hover)", color: "var(--text-primary)", border: "1px solid var(--border)" }}
|
|
122
|
+
>
|
|
123
|
+
Cancel
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
interface DataConsentModalProps {
|
|
4
|
+
onAccept: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function DataConsentModal({ onAccept }: DataConsentModalProps) {
|
|
8
|
+
const [scrolledToBottom, setScrolledToBottom] = useState(false);
|
|
9
|
+
const [timerElapsed, setTimerElapsed] = useState(false);
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const timer = setTimeout(() => setTimerElapsed(true), 3000);
|
|
13
|
+
return () => clearTimeout(timer);
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
const canAccept = scrolledToBottom || timerElapsed;
|
|
17
|
+
|
|
18
|
+
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
|
|
19
|
+
const el = e.currentTarget;
|
|
20
|
+
if (el.scrollHeight - el.scrollTop - el.clientHeight < 20) {
|
|
21
|
+
setScrolledToBottom(true);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className="fixed inset-0 flex items-center justify-center z-50 p-4"
|
|
28
|
+
style={{ background: "var(--bg-secondary)" }}
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
className="w-full max-w-lg flex flex-col rounded-md"
|
|
32
|
+
style={{
|
|
33
|
+
background: "var(--bg-surface)",
|
|
34
|
+
boxShadow: "var(--shadow-lg)",
|
|
35
|
+
maxHeight: "90vh",
|
|
36
|
+
}}
|
|
37
|
+
>
|
|
38
|
+
{/* Header */}
|
|
39
|
+
<div className="px-6 pt-6 pb-4 shrink-0">
|
|
40
|
+
<div className="flex items-center gap-3 mb-1">
|
|
41
|
+
<svg className="w-6 h-6 shrink-0" viewBox="0 0 24 24" fill="none" stroke="var(--bg-active)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
42
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
43
|
+
</svg>
|
|
44
|
+
<h1 className="text-h1 font-bold" style={{ color: "var(--text-primary)" }}>
|
|
45
|
+
How Your Data Works in BotsChat
|
|
46
|
+
</h1>
|
|
47
|
+
</div>
|
|
48
|
+
<p className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
49
|
+
Please review before continuing
|
|
50
|
+
</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
{/* Scrollable content */}
|
|
54
|
+
<div
|
|
55
|
+
className="flex-1 min-h-0 overflow-y-auto px-6"
|
|
56
|
+
onScroll={handleScroll}
|
|
57
|
+
>
|
|
58
|
+
<div className="space-y-5 pb-2">
|
|
59
|
+
{/* Message Relay */}
|
|
60
|
+
<Section
|
|
61
|
+
icon={
|
|
62
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
63
|
+
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
|
|
64
|
+
</svg>
|
|
65
|
+
}
|
|
66
|
+
title="Message Relay"
|
|
67
|
+
>
|
|
68
|
+
BotsChat acts as a WebSocket relay between your browser and your own
|
|
69
|
+
OpenClaw AI gateway. Messages you send are transmitted through
|
|
70
|
+
BotsChat Cloud to reach your gateway.
|
|
71
|
+
</Section>
|
|
72
|
+
|
|
73
|
+
{/* E2E Encryption */}
|
|
74
|
+
<Section
|
|
75
|
+
icon={
|
|
76
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
77
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
78
|
+
<path d="M7 11V7a5 5 0 0110 0v4" />
|
|
79
|
+
</svg>
|
|
80
|
+
}
|
|
81
|
+
title="End-to-End Encryption"
|
|
82
|
+
>
|
|
83
|
+
When E2E encryption is enabled, the server only stores ciphertext
|
|
84
|
+
it cannot read. Your encryption key never leaves your device.
|
|
85
|
+
</Section>
|
|
86
|
+
|
|
87
|
+
{/* AI Processing */}
|
|
88
|
+
<Section
|
|
89
|
+
icon={
|
|
90
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
91
|
+
<rect x="4" y="4" width="16" height="16" rx="2" ry="2" />
|
|
92
|
+
<rect x="9" y="9" width="6" height="6" />
|
|
93
|
+
<line x1="9" y1="1" x2="9" y2="4" />
|
|
94
|
+
<line x1="15" y1="1" x2="15" y2="4" />
|
|
95
|
+
<line x1="9" y1="20" x2="9" y2="23" />
|
|
96
|
+
<line x1="15" y1="20" x2="15" y2="23" />
|
|
97
|
+
<line x1="20" y1="9" x2="23" y2="9" />
|
|
98
|
+
<line x1="20" y1="14" x2="23" y2="14" />
|
|
99
|
+
<line x1="1" y1="9" x2="4" y2="9" />
|
|
100
|
+
<line x1="1" y1="14" x2="4" y2="14" />
|
|
101
|
+
</svg>
|
|
102
|
+
}
|
|
103
|
+
title="AI Processing"
|
|
104
|
+
>
|
|
105
|
+
AI processing happens on your OpenClaw gateway using AI services
|
|
106
|
+
you configure (such as OpenAI, Anthropic, Google, Azure, etc.).
|
|
107
|
+
BotsChat does not choose or control which AI service processes your
|
|
108
|
+
data.
|
|
109
|
+
</Section>
|
|
110
|
+
|
|
111
|
+
{/* Your API Keys */}
|
|
112
|
+
<Section
|
|
113
|
+
icon={
|
|
114
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
115
|
+
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 017.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
|
116
|
+
</svg>
|
|
117
|
+
}
|
|
118
|
+
title="Your API Keys"
|
|
119
|
+
>
|
|
120
|
+
Your API keys are stored on your OpenClaw gateway machine and never
|
|
121
|
+
pass through BotsChat Cloud.
|
|
122
|
+
</Section>
|
|
123
|
+
|
|
124
|
+
{/* Third-Party Services */}
|
|
125
|
+
<Section
|
|
126
|
+
icon={
|
|
127
|
+
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
128
|
+
<circle cx="12" cy="12" r="10" />
|
|
129
|
+
<line x1="2" y1="12" x2="22" y2="12" />
|
|
130
|
+
<path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
|
131
|
+
</svg>
|
|
132
|
+
}
|
|
133
|
+
title="Third-Party Services"
|
|
134
|
+
>
|
|
135
|
+
BotsChat Cloud uses Cloudflare for hosting, database, and media
|
|
136
|
+
storage. Authentication is provided by Google and GitHub OAuth. No
|
|
137
|
+
data is sold or shared with advertisers.
|
|
138
|
+
</Section>
|
|
139
|
+
|
|
140
|
+
{/* What You Agree To */}
|
|
141
|
+
<div
|
|
142
|
+
className="rounded-md p-4"
|
|
143
|
+
style={{ background: "var(--bg-secondary)", border: "1px solid var(--border)" }}
|
|
144
|
+
>
|
|
145
|
+
<h3 className="text-body font-bold mb-3" style={{ color: "var(--text-primary)" }}>
|
|
146
|
+
What You Agree To
|
|
147
|
+
</h3>
|
|
148
|
+
<ul className="space-y-2">
|
|
149
|
+
<AgreementItem>
|
|
150
|
+
Messages sent through BotsChat may be processed by third-party
|
|
151
|
+
AI services configured in your OpenClaw gateway
|
|
152
|
+
</AgreementItem>
|
|
153
|
+
<AgreementItem>
|
|
154
|
+
Your chat data is stored on Cloudflare infrastructure
|
|
155
|
+
</AgreementItem>
|
|
156
|
+
<AgreementItem>
|
|
157
|
+
You can enable E2E encryption for additional privacy
|
|
158
|
+
</AgreementItem>
|
|
159
|
+
<AgreementItem>
|
|
160
|
+
You can delete your account and all data at any time
|
|
161
|
+
</AgreementItem>
|
|
162
|
+
</ul>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
{/* Footer */}
|
|
168
|
+
<div
|
|
169
|
+
className="px-6 py-4 shrink-0"
|
|
170
|
+
style={{ borderTop: "1px solid var(--border)" }}
|
|
171
|
+
>
|
|
172
|
+
<button
|
|
173
|
+
onClick={onAccept}
|
|
174
|
+
disabled={!canAccept}
|
|
175
|
+
className="w-full py-2.5 font-bold text-body text-white rounded-sm transition-all"
|
|
176
|
+
style={{
|
|
177
|
+
background: canAccept ? "var(--bg-active)" : "var(--bg-hover, #3a3d41)",
|
|
178
|
+
cursor: canAccept ? "pointer" : "not-allowed",
|
|
179
|
+
opacity: canAccept ? 1 : 0.5,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
I Understand & Accept
|
|
183
|
+
</button>
|
|
184
|
+
<div className="flex items-center justify-center gap-2 mt-3">
|
|
185
|
+
<a
|
|
186
|
+
href="https://botschat.app/privacy.html"
|
|
187
|
+
target="_blank"
|
|
188
|
+
rel="noopener noreferrer"
|
|
189
|
+
className="text-tiny hover:underline"
|
|
190
|
+
style={{ color: "var(--text-muted)" }}
|
|
191
|
+
>
|
|
192
|
+
Privacy Policy
|
|
193
|
+
</a>
|
|
194
|
+
<span className="text-tiny" style={{ color: "var(--text-muted)" }}>·</span>
|
|
195
|
+
<a
|
|
196
|
+
href="https://botschat.app/terms.html"
|
|
197
|
+
target="_blank"
|
|
198
|
+
rel="noopener noreferrer"
|
|
199
|
+
className="text-tiny hover:underline"
|
|
200
|
+
style={{ color: "var(--text-muted)" }}
|
|
201
|
+
>
|
|
202
|
+
Terms of Service
|
|
203
|
+
</a>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
</div>
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function Section({ icon, title, children }: { icon: React.ReactNode; title: string; children: React.ReactNode }) {
|
|
212
|
+
return (
|
|
213
|
+
<div className="flex gap-3">
|
|
214
|
+
<div
|
|
215
|
+
className="shrink-0 w-8 h-8 rounded-md flex items-center justify-center mt-0.5"
|
|
216
|
+
style={{ background: "var(--bg-secondary)", color: "var(--text-secondary)" }}
|
|
217
|
+
>
|
|
218
|
+
{icon}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="min-w-0">
|
|
221
|
+
<h3 className="text-body font-bold mb-1" style={{ color: "var(--text-primary)" }}>
|
|
222
|
+
{title}
|
|
223
|
+
</h3>
|
|
224
|
+
<p className="text-caption" style={{ color: "var(--text-secondary)", lineHeight: 1.6 }}>
|
|
225
|
+
{children}
|
|
226
|
+
</p>
|
|
227
|
+
</div>
|
|
228
|
+
</div>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function AgreementItem({ children }: { children: React.ReactNode }) {
|
|
233
|
+
return (
|
|
234
|
+
<li className="flex gap-2 text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
235
|
+
<svg
|
|
236
|
+
className="w-4 h-4 shrink-0 mt-0.5"
|
|
237
|
+
viewBox="0 0 20 20"
|
|
238
|
+
fill="var(--bg-active)"
|
|
239
|
+
>
|
|
240
|
+
<path
|
|
241
|
+
fillRule="evenodd"
|
|
242
|
+
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
|
243
|
+
clipRule="evenodd"
|
|
244
|
+
/>
|
|
245
|
+
</svg>
|
|
246
|
+
<span style={{ lineHeight: 1.5 }}>{children}</span>
|
|
247
|
+
</li>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -3,7 +3,7 @@ import { authApi, setToken, setRefreshToken } from "../api";
|
|
|
3
3
|
import type { AuthConfig } from "../api";
|
|
4
4
|
import { useAppDispatch } from "../store";
|
|
5
5
|
import { dlog } from "../debug-log";
|
|
6
|
-
import { isFirebaseConfigured, signInWithGoogle, signInWithGitHub } from "../firebase";
|
|
6
|
+
import { isFirebaseConfigured, signInWithGoogle, signInWithGitHub, signInWithApple } from "../firebase";
|
|
7
7
|
|
|
8
8
|
/** Google "G" logo SVG */
|
|
9
9
|
function GoogleIcon() {
|
|
@@ -26,6 +26,15 @@ function GitHubIcon() {
|
|
|
26
26
|
);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** Apple logo SVG */
|
|
30
|
+
function AppleIcon() {
|
|
31
|
+
return (
|
|
32
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
|
|
33
|
+
<path d="M17.05 20.28c-.98.95-2.05.88-3.08.4-1.09-.5-2.08-.48-3.24 0-1.44.62-2.2.44-3.06-.4C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
export function LoginPage() {
|
|
30
39
|
const dispatch = useAppDispatch();
|
|
31
40
|
const [isRegister, setIsRegister] = useState(false);
|
|
@@ -34,7 +43,7 @@ export function LoginPage() {
|
|
|
34
43
|
const [displayName, setDisplayName] = useState("");
|
|
35
44
|
const [error, setError] = useState("");
|
|
36
45
|
const [loading, setLoading] = useState(false);
|
|
37
|
-
const [oauthLoading, setOauthLoading] = useState<"google" | "github" | null>(null);
|
|
46
|
+
const [oauthLoading, setOauthLoading] = useState<"google" | "github" | "apple" | null>(null);
|
|
38
47
|
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null);
|
|
39
48
|
|
|
40
49
|
const firebaseEnabled = isFirebaseConfigured();
|
|
@@ -44,7 +53,7 @@ export function LoginPage() {
|
|
|
44
53
|
useEffect(() => {
|
|
45
54
|
authApi.config().then(setAuthConfig).catch(() => {
|
|
46
55
|
// Fallback: assume email enabled (local dev) if config endpoint fails
|
|
47
|
-
setAuthConfig({ emailEnabled: true, googleEnabled: firebaseEnabled, githubEnabled: firebaseEnabled });
|
|
56
|
+
setAuthConfig({ emailEnabled: true, googleEnabled: firebaseEnabled, githubEnabled: firebaseEnabled, appleEnabled: firebaseEnabled });
|
|
48
57
|
});
|
|
49
58
|
}, [firebaseEnabled]);
|
|
50
59
|
|
|
@@ -90,26 +99,24 @@ export function LoginPage() {
|
|
|
90
99
|
}
|
|
91
100
|
};
|
|
92
101
|
|
|
93
|
-
const handleOAuthSignIn = async (provider: "google" | "github") => {
|
|
102
|
+
const handleOAuthSignIn = async (provider: "google" | "github" | "apple") => {
|
|
94
103
|
setError("");
|
|
95
104
|
setOauthLoading(provider);
|
|
96
105
|
|
|
97
|
-
|
|
106
|
+
const timeoutMs = provider === "apple" ? 120000 : 30000;
|
|
98
107
|
const timeout = setTimeout(() => {
|
|
99
108
|
setOauthLoading(null);
|
|
100
|
-
|
|
101
|
-
}, 30000);
|
|
109
|
+
}, timeoutMs);
|
|
102
110
|
|
|
103
111
|
try {
|
|
104
112
|
dlog.info("Auth", `Starting ${provider} sign-in`);
|
|
105
|
-
const signInFn = provider === "google" ? signInWithGoogle : signInWithGitHub;
|
|
113
|
+
const signInFn = provider === "google" ? signInWithGoogle : provider === "github" ? signInWithGitHub : signInWithApple;
|
|
106
114
|
const { idToken } = await signInFn();
|
|
107
115
|
dlog.info("Auth", `Got Firebase ID token from ${provider}, verifying with backend`);
|
|
108
116
|
const res = await authApi.firebase(idToken);
|
|
109
117
|
dlog.info("Auth", `${provider} sign-in success — user ${res.id} (${res.email})`);
|
|
110
118
|
handleAuthSuccess(res);
|
|
111
119
|
} catch (err) {
|
|
112
|
-
// Don't show error for user-cancelled popup
|
|
113
120
|
if (err instanceof Error && (
|
|
114
121
|
err.message.includes("popup-closed-by-user") ||
|
|
115
122
|
err.message.includes("cancelled")
|
|
@@ -179,6 +186,28 @@ export function LoginPage() {
|
|
|
179
186
|
{configLoaded && firebaseEnabled && (
|
|
180
187
|
<>
|
|
181
188
|
<div className="space-y-3">
|
|
189
|
+
{/* Apple — listed first per Apple HIG */}
|
|
190
|
+
<button
|
|
191
|
+
type="button"
|
|
192
|
+
onClick={() => handleOAuthSignIn("apple")}
|
|
193
|
+
disabled={anyLoading}
|
|
194
|
+
className="w-full flex items-center justify-center gap-3 py-2.5 px-4 font-medium text-body rounded-sm disabled:opacity-50 transition-colors hover:brightness-95"
|
|
195
|
+
style={{
|
|
196
|
+
background: "var(--bg-surface)",
|
|
197
|
+
color: "var(--text-primary)",
|
|
198
|
+
border: "1px solid var(--border)",
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
{oauthLoading === "apple" ? (
|
|
202
|
+
<span>Signing in...</span>
|
|
203
|
+
) : (
|
|
204
|
+
<>
|
|
205
|
+
<AppleIcon />
|
|
206
|
+
<span>Continue with Apple</span>
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
</button>
|
|
210
|
+
|
|
182
211
|
{/* Google */}
|
|
183
212
|
<button
|
|
184
213
|
type="button"
|
|
@@ -339,6 +368,17 @@ export function LoginPage() {
|
|
|
339
368
|
</div>
|
|
340
369
|
</>
|
|
341
370
|
)}
|
|
371
|
+
|
|
372
|
+
{/* Privacy & Terms links */}
|
|
373
|
+
<div className="mt-6 text-center">
|
|
374
|
+
<a href="https://botschat.app/privacy.html" target="_blank" rel="noopener noreferrer" className="text-tiny hover:underline" style={{ color: "var(--text-muted)" }}>
|
|
375
|
+
Privacy Policy
|
|
376
|
+
</a>
|
|
377
|
+
<span className="mx-2 text-tiny" style={{ color: "var(--text-muted)" }}>·</span>
|
|
378
|
+
<a href="https://botschat.app/terms.html" target="_blank" rel="noopener noreferrer" className="text-tiny hover:underline" style={{ color: "var(--text-muted)" }}>
|
|
379
|
+
Terms of Service
|
|
380
|
+
</a>
|
|
381
|
+
</div>
|
|
342
382
|
</div>
|
|
343
383
|
</div>
|
|
344
384
|
</div>
|
|
@@ -32,6 +32,8 @@ const firebaseConfig = {
|
|
|
32
32
|
apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string,
|
|
33
33
|
authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string,
|
|
34
34
|
projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string,
|
|
35
|
+
messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string,
|
|
36
|
+
appId: import.meta.env.VITE_FIREBASE_APP_ID as string,
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
let app: FirebaseApp | null = null;
|
|
@@ -42,6 +44,14 @@ export function isFirebaseConfigured(): boolean {
|
|
|
42
44
|
return !!(firebaseConfig.apiKey && firebaseConfig.authDomain && firebaseConfig.projectId);
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/** Ensure the Firebase app is initialized (for FCM, independent of OAuth). */
|
|
48
|
+
export function ensureFirebaseApp(): FirebaseApp | null {
|
|
49
|
+
if (app) return app;
|
|
50
|
+
if (!isFirebaseConfigured()) return null;
|
|
51
|
+
app = initializeApp(firebaseConfig);
|
|
52
|
+
return app;
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
function getFirebaseAuth(): Auth {
|
|
46
56
|
if (!auth) {
|
|
47
57
|
if (!isFirebaseConfigured()) {
|
|
@@ -64,7 +74,7 @@ export type FirebaseSignInResult = {
|
|
|
64
74
|
email: string;
|
|
65
75
|
displayName: string | null;
|
|
66
76
|
photoURL: string | null;
|
|
67
|
-
provider: "google" | "github";
|
|
77
|
+
provider: "google" | "github" | "apple";
|
|
68
78
|
};
|
|
69
79
|
|
|
70
80
|
// ---------------------------------------------------------------------------
|
|
@@ -95,12 +105,14 @@ async function ensureNativeGoogleInit(): Promise<void> {
|
|
|
95
105
|
|
|
96
106
|
const iosClientId = import.meta.env.VITE_GOOGLE_IOS_CLIENT_ID as string | undefined;
|
|
97
107
|
const webClientId = import.meta.env.VITE_GOOGLE_WEB_CLIENT_ID as string | undefined;
|
|
108
|
+
const platform = Capacitor.getPlatform();
|
|
98
109
|
|
|
99
|
-
console.log("[NativeGoogleSignIn] initialize: iOSClientId =", iosClientId?.substring(0, 20) + "...", "webClientId =", webClientId?.substring(0, 20) + "...");
|
|
110
|
+
console.log("[NativeGoogleSignIn] initialize: platform =", platform, "iOSClientId =", iosClientId?.substring(0, 20) + "...", "webClientId =", webClientId?.substring(0, 20) + "...");
|
|
100
111
|
|
|
101
112
|
await withTimeout(
|
|
102
113
|
SocialLogin.initialize({
|
|
103
114
|
google: {
|
|
115
|
+
webClientId: webClientId || undefined,
|
|
104
116
|
iOSClientId: iosClientId || undefined,
|
|
105
117
|
iOSServerClientId: webClientId || undefined,
|
|
106
118
|
},
|
|
@@ -216,3 +228,78 @@ export async function signInWithGitHub(): Promise<FirebaseSignInResult> {
|
|
|
216
228
|
provider: "github",
|
|
217
229
|
};
|
|
218
230
|
}
|
|
231
|
+
|
|
232
|
+
// ---------------------------------------------------------------------------
|
|
233
|
+
// Apple Sign-In
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Native Apple Sign-In on iOS via @capgo/capacitor-social-login,
|
|
238
|
+
* then exchange for Firebase credential.
|
|
239
|
+
*/
|
|
240
|
+
async function nativeAppleSignIn(): Promise<FirebaseSignInResult> {
|
|
241
|
+
console.log("[NativeAppleSignIn] Step 1: initializing");
|
|
242
|
+
const { SocialLogin } = await import("@capgo/capacitor-social-login");
|
|
243
|
+
|
|
244
|
+
if (!_socialLoginInitialized) {
|
|
245
|
+
await withTimeout(
|
|
246
|
+
SocialLogin.initialize({ apple: {} }),
|
|
247
|
+
10000,
|
|
248
|
+
"SocialLogin.initialize(apple)",
|
|
249
|
+
);
|
|
250
|
+
_socialLoginInitialized = true;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
console.log("[NativeAppleSignIn] Step 2: calling SocialLogin.login()");
|
|
254
|
+
const res = await SocialLogin.login({
|
|
255
|
+
provider: "apple",
|
|
256
|
+
options: { scopes: ["email", "name"] },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
console.log("[NativeAppleSignIn] Step 3: SocialLogin.login() returned", JSON.stringify(res).substring(0, 200));
|
|
260
|
+
|
|
261
|
+
const appleResult = res.result as any;
|
|
262
|
+
const appleIdToken = appleResult.idToken;
|
|
263
|
+
if (!appleIdToken) {
|
|
264
|
+
throw new Error("Apple Sign-In did not return an idToken");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Send the Apple ID token directly to the backend — skip Firebase client
|
|
268
|
+
// (signInWithCredential hangs in WKWebView on real devices, same as Google)
|
|
269
|
+
console.log("[NativeAppleSignIn] Step 4: Skipping Firebase client, sending Apple ID token directly to backend");
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
idToken: appleIdToken,
|
|
273
|
+
email: appleResult.profile?.email ?? appleResult.email ?? "",
|
|
274
|
+
displayName: appleResult.profile?.name ?? appleResult.fullName?.givenName ?? null,
|
|
275
|
+
photoURL: null,
|
|
276
|
+
provider: "apple",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Sign in with Apple.
|
|
282
|
+
* - Web: Firebase popup with Apple OAuthProvider
|
|
283
|
+
* - Native iOS: Native Apple Sign-In via @capgo/capacitor-social-login → Firebase credential
|
|
284
|
+
*/
|
|
285
|
+
export async function signInWithApple(): Promise<FirebaseSignInResult> {
|
|
286
|
+
if (Capacitor.isNativePlatform()) {
|
|
287
|
+
return nativeAppleSignIn();
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const firebaseAuth = getFirebaseAuth();
|
|
291
|
+
const provider = new OAuthProvider("apple.com");
|
|
292
|
+
provider.addScope("email");
|
|
293
|
+
provider.addScope("name");
|
|
294
|
+
|
|
295
|
+
const result = await signInWithPopup(firebaseAuth, provider);
|
|
296
|
+
const idToken = await result.user.getIdToken();
|
|
297
|
+
|
|
298
|
+
return {
|
|
299
|
+
idToken,
|
|
300
|
+
email: result.user.email ?? "",
|
|
301
|
+
displayName: result.user.displayName,
|
|
302
|
+
photoURL: result.user.photoURL,
|
|
303
|
+
provider: "apple",
|
|
304
|
+
};
|
|
305
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Foreground/background detection — notifies the ConnectionDO via WebSocket
|
|
3
|
+
* so it knows whether to send push notifications.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Capacitor } from "@capacitor/core";
|
|
7
|
+
import type { BotsChatWSClient } from "./ws";
|
|
8
|
+
import { dlog } from "./debug-log";
|
|
9
|
+
|
|
10
|
+
export function setupForegroundDetection(wsClient: BotsChatWSClient): () => void {
|
|
11
|
+
const notifyForeground = () => {
|
|
12
|
+
wsClient.send({ type: "foreground.enter" });
|
|
13
|
+
dlog.info("Foreground", "Entered foreground");
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const notifyBackground = () => {
|
|
17
|
+
wsClient.send({ type: "foreground.leave" });
|
|
18
|
+
dlog.info("Foreground", "Entered background");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (Capacitor.isNativePlatform()) {
|
|
22
|
+
let cleanup: (() => void) | null = null;
|
|
23
|
+
|
|
24
|
+
import("@capacitor/app").then(({ App }) => {
|
|
25
|
+
const handle = App.addListener("appStateChange", ({ isActive }) => {
|
|
26
|
+
if (isActive) notifyForeground();
|
|
27
|
+
else notifyBackground();
|
|
28
|
+
});
|
|
29
|
+
cleanup = () => handle.then((h) => h.remove());
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Report initial foreground state once WS is connected
|
|
33
|
+
notifyForeground();
|
|
34
|
+
|
|
35
|
+
return () => cleanup?.();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Web: Use Page Visibility API
|
|
39
|
+
const handleVisibilityChange = () => {
|
|
40
|
+
if (document.hidden) notifyBackground();
|
|
41
|
+
else notifyForeground();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
45
|
+
|
|
46
|
+
if (!document.hidden) notifyForeground();
|
|
47
|
+
|
|
48
|
+
return () => {
|
|
49
|
+
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
50
|
+
};
|
|
51
|
+
}
|