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.
Files changed (37) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +7 -1
  4. package/packages/api/src/do/connection-do.ts +90 -1
  5. package/packages/api/src/env.ts +2 -0
  6. package/packages/api/src/index.ts +4 -1
  7. package/packages/api/src/routes/auth.ts +39 -6
  8. package/packages/api/src/routes/push.ts +52 -0
  9. package/packages/api/src/utils/fcm.ts +167 -0
  10. package/packages/api/src/utils/firebase.ts +89 -1
  11. package/packages/plugin/package.json +1 -1
  12. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  13. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  14. package/packages/web/dist/assets/{index-CCBhODDo.css → index-Bd_RDcgO.css} +1 -1
  15. package/packages/web/dist/assets/{index-CCFgKLX_.js → index-Civeg2lm.js} +1 -1
  16. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  17. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  18. package/packages/web/dist/assets/{index-Dx64BDkP.js → index-lVB82JKU.js} +1 -1
  19. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  20. package/packages/web/dist/assets/{web-DJQW-VLX.js → web-CUXjh_UA.js} +1 -1
  21. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  22. package/packages/web/dist/index.html +6 -4
  23. package/packages/web/dist/sw.js +158 -1
  24. package/packages/web/index.html +4 -2
  25. package/packages/web/src/App.tsx +42 -2
  26. package/packages/web/src/api.ts +10 -0
  27. package/packages/web/src/components/AccountSettings.tsx +131 -0
  28. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  29. package/packages/web/src/components/LoginPage.tsx +49 -9
  30. package/packages/web/src/firebase.ts +89 -2
  31. package/packages/web/src/foreground.ts +51 -0
  32. package/packages/web/src/main.tsx +2 -1
  33. package/packages/web/src/push.ts +205 -0
  34. package/scripts/dev.sh +139 -13
  35. package/scripts/mock-openclaw.mjs +382 -0
  36. package/packages/web/dist/assets/index-D8mBAwjS.js +0 -1516
  37. 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 &amp; 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
- // Timeout protection: if sign-in hangs for >30s, reset the UI
106
+ const timeoutMs = provider === "apple" ? 120000 : 30000;
98
107
  const timeout = setTimeout(() => {
99
108
  setOauthLoading(null);
100
- setError(`${provider} sign-in timed out. Please try again.`);
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
+ }