botschat 0.1.10 → 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 +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -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-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- 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-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- 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/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import React from "react";
|
|
1
|
+
import React, { useState } from "react";
|
|
2
2
|
import { useAppState, useAppDispatch } from "../store";
|
|
3
|
+
import { tasksApi, channelsApi } from "../api";
|
|
4
|
+
import { dlog } from "../debug-log";
|
|
3
5
|
|
|
4
6
|
function relativeTime(ts: number): string {
|
|
5
7
|
const now = Date.now() / 1000;
|
|
@@ -10,49 +12,112 @@ function relativeTime(ts: number): string {
|
|
|
10
12
|
return `${Math.floor(diff / 86400)}d ago`;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function CronSidebar() {
|
|
15
|
+
export function CronSidebar({ onNavigate }: { onNavigate?: () => void } = {}) {
|
|
14
16
|
const state = useAppState();
|
|
15
17
|
const dispatch = useAppDispatch();
|
|
18
|
+
const [showCreate, setShowCreate] = useState(false);
|
|
19
|
+
const [newName, setNewName] = useState("");
|
|
16
20
|
|
|
17
21
|
const handleSelect = (taskId: string) => {
|
|
22
|
+
// Ensure activeView is "automations" so cron data loads correctly
|
|
23
|
+
if (state.activeView !== "automations") {
|
|
24
|
+
dispatch({ type: "SET_ACTIVE_VIEW", view: "automations" });
|
|
25
|
+
}
|
|
18
26
|
dispatch({ type: "SELECT_CRON_TASK", taskId });
|
|
27
|
+
onNavigate?.();
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const handleCreateTask = async () => {
|
|
31
|
+
if (!newName.trim()) return;
|
|
32
|
+
// Find a suitable channel — prefer "Default", then first available
|
|
33
|
+
let channelId = state.channels.find((c) => c.name === "Default")?.id
|
|
34
|
+
?? state.channels[0]?.id;
|
|
35
|
+
if (!channelId) {
|
|
36
|
+
// Auto-create a "Default" channel if none exist
|
|
37
|
+
try {
|
|
38
|
+
const ch = await channelsApi.create({ name: "Default" });
|
|
39
|
+
channelId = ch.id;
|
|
40
|
+
const { channels } = await channelsApi.list();
|
|
41
|
+
dispatch({ type: "SET_CHANNELS", channels });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
dlog.error("Cron", `Failed to create default channel: ${err}`);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const task = await tasksApi.create(channelId, { name: newName.trim(), kind: "background" });
|
|
49
|
+
dlog.info("Cron", `Created automation: ${task.name} (${task.id})`);
|
|
50
|
+
// Reload cron tasks
|
|
51
|
+
const { tasks } = await tasksApi.listAll("background");
|
|
52
|
+
dispatch({ type: "SET_CRON_TASKS", cronTasks: tasks });
|
|
53
|
+
dispatch({ type: "SELECT_CRON_TASK", taskId: task.id });
|
|
54
|
+
setShowCreate(false);
|
|
55
|
+
setNewName("");
|
|
56
|
+
onNavigate?.();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
dlog.error("Cron", `Failed to create automation: ${err}`);
|
|
59
|
+
}
|
|
19
60
|
};
|
|
20
61
|
|
|
21
62
|
return (
|
|
22
63
|
<div
|
|
23
|
-
className="flex flex-col
|
|
64
|
+
className="flex flex-col"
|
|
24
65
|
style={{ background: "var(--bg-secondary)" }}
|
|
25
66
|
>
|
|
26
|
-
{/* Header */}
|
|
27
|
-
<div className="
|
|
28
|
-
<
|
|
67
|
+
{/* Header with + button */}
|
|
68
|
+
<div className="w-full flex items-center px-4 py-1.5">
|
|
69
|
+
<button
|
|
70
|
+
className="flex items-center gap-1 text-tiny uppercase tracking-wider text-[--text-sidebar] hover:text-[--text-sidebar-active] transition-colors"
|
|
71
|
+
>
|
|
29
72
|
Automations
|
|
73
|
+
</button>
|
|
74
|
+
<span className="ml-1.5 text-tiny text-[--text-muted]">
|
|
75
|
+
{state.cronTasks.length > 0 && `${state.cronTasks.length}`}
|
|
30
76
|
</span>
|
|
77
|
+
<button
|
|
78
|
+
onClick={() => setShowCreate(!showCreate)}
|
|
79
|
+
className="ml-auto p-0.5 rounded transition-colors text-[--text-sidebar] hover:text-[--text-sidebar-active] hover:bg-[--sidebar-hover]"
|
|
80
|
+
title="New automation"
|
|
81
|
+
>
|
|
82
|
+
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
83
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
|
84
|
+
</svg>
|
|
85
|
+
</button>
|
|
31
86
|
</div>
|
|
32
87
|
|
|
33
|
-
{/*
|
|
34
|
-
|
|
35
|
-
<div className="
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
88
|
+
{/* Inline create automation form */}
|
|
89
|
+
{showCreate && (
|
|
90
|
+
<div className="px-4 py-2 space-y-2">
|
|
91
|
+
<input
|
|
92
|
+
type="text"
|
|
93
|
+
placeholder="Automation name"
|
|
94
|
+
value={newName}
|
|
95
|
+
onChange={(e) => setNewName(e.target.value)}
|
|
96
|
+
onKeyDown={(e) => e.key === "Enter" && !e.nativeEvent.isComposing && handleCreateTask()}
|
|
97
|
+
className="w-full px-2 py-1.5 text-caption text-[--text-sidebar] rounded-sm focus:outline-none placeholder:text-[--text-muted]"
|
|
98
|
+
style={{ background: "var(--sidebar-hover)", border: "1px solid var(--sidebar-border)" }}
|
|
99
|
+
autoFocus
|
|
39
100
|
/>
|
|
40
|
-
<
|
|
41
|
-
|
|
42
|
-
|
|
101
|
+
<div className="flex gap-2">
|
|
102
|
+
<button
|
|
103
|
+
onClick={handleCreateTask}
|
|
104
|
+
className="flex-1 px-3 py-1.5 text-caption bg-[--bg-active] text-white rounded-sm font-bold hover:brightness-110"
|
|
105
|
+
>
|
|
106
|
+
Create
|
|
107
|
+
</button>
|
|
108
|
+
<button
|
|
109
|
+
onClick={() => { setShowCreate(false); setNewName(""); }}
|
|
110
|
+
className="px-3 py-1.5 text-caption text-[--text-muted] hover:text-[--text-sidebar]"
|
|
111
|
+
>
|
|
112
|
+
Cancel
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
43
115
|
</div>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
{/* Task count */}
|
|
47
|
-
<div className="px-4 pb-2">
|
|
48
|
-
<span className="text-tiny text-[--text-muted]">
|
|
49
|
-
{state.cronTasks.length} cron job{state.cronTasks.length !== 1 ? "s" : ""}
|
|
50
|
-
</span>
|
|
51
|
-
</div>
|
|
116
|
+
)}
|
|
52
117
|
|
|
53
118
|
{/* Job list */}
|
|
54
119
|
<div className="flex-1 overflow-y-auto sidebar-scroll">
|
|
55
|
-
{state.cronTasks.length === 0 ? (
|
|
120
|
+
{state.cronTasks.length === 0 && !showCreate ? (
|
|
56
121
|
<div className="px-4 py-8 text-center">
|
|
57
122
|
<svg
|
|
58
123
|
className="w-10 h-10 mx-auto mb-3"
|
|
@@ -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,11 +53,12 @@ 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
|
|
|
51
|
-
|
|
60
|
+
// Email/password login is permanently disabled — only OAuth (Google/GitHub) is allowed.
|
|
61
|
+
const emailEnabled = false;
|
|
52
62
|
const configLoaded = authConfig !== null;
|
|
53
63
|
const hasAnyLoginMethod = configLoaded && (firebaseEnabled || emailEnabled);
|
|
54
64
|
|
|
@@ -89,20 +99,24 @@ export function LoginPage() {
|
|
|
89
99
|
}
|
|
90
100
|
};
|
|
91
101
|
|
|
92
|
-
const handleOAuthSignIn = async (provider: "google" | "github") => {
|
|
102
|
+
const handleOAuthSignIn = async (provider: "google" | "github" | "apple") => {
|
|
93
103
|
setError("");
|
|
94
104
|
setOauthLoading(provider);
|
|
95
105
|
|
|
106
|
+
const timeoutMs = provider === "apple" ? 120000 : 30000;
|
|
107
|
+
const timeout = setTimeout(() => {
|
|
108
|
+
setOauthLoading(null);
|
|
109
|
+
}, timeoutMs);
|
|
110
|
+
|
|
96
111
|
try {
|
|
97
112
|
dlog.info("Auth", `Starting ${provider} sign-in`);
|
|
98
|
-
const signInFn = provider === "google" ? signInWithGoogle : signInWithGitHub;
|
|
113
|
+
const signInFn = provider === "google" ? signInWithGoogle : provider === "github" ? signInWithGitHub : signInWithApple;
|
|
99
114
|
const { idToken } = await signInFn();
|
|
100
115
|
dlog.info("Auth", `Got Firebase ID token from ${provider}, verifying with backend`);
|
|
101
116
|
const res = await authApi.firebase(idToken);
|
|
102
117
|
dlog.info("Auth", `${provider} sign-in success — user ${res.id} (${res.email})`);
|
|
103
118
|
handleAuthSuccess(res);
|
|
104
119
|
} catch (err) {
|
|
105
|
-
// Don't show error for user-cancelled popup
|
|
106
120
|
if (err instanceof Error && (
|
|
107
121
|
err.message.includes("popup-closed-by-user") ||
|
|
108
122
|
err.message.includes("cancelled")
|
|
@@ -114,6 +128,7 @@ export function LoginPage() {
|
|
|
114
128
|
setError(message);
|
|
115
129
|
}
|
|
116
130
|
} finally {
|
|
131
|
+
clearTimeout(timeout);
|
|
117
132
|
setOauthLoading(null);
|
|
118
133
|
}
|
|
119
134
|
};
|
|
@@ -171,6 +186,28 @@ export function LoginPage() {
|
|
|
171
186
|
{configLoaded && firebaseEnabled && (
|
|
172
187
|
<>
|
|
173
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
|
+
|
|
174
211
|
{/* Google */}
|
|
175
212
|
<button
|
|
176
213
|
type="button"
|
|
@@ -331,6 +368,17 @@ export function LoginPage() {
|
|
|
331
368
|
</div>
|
|
332
369
|
</>
|
|
333
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>
|
|
334
382
|
</div>
|
|
335
383
|
</div>
|
|
336
384
|
</div>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import React, { useState, useCallback, useMemo } from "react";
|
|
1
|
+
import React, { useState, useCallback, useMemo, useEffect } from "react";
|
|
2
|
+
import { E2eService } from "../e2e";
|
|
2
3
|
import ReactMarkdown from "react-markdown";
|
|
3
4
|
import remarkGfm from "remark-gfm";
|
|
4
5
|
import rehypeHighlight from "rehype-highlight";
|
|
@@ -25,6 +26,10 @@ type ParsedAction = {
|
|
|
25
26
|
type MessageContentProps = {
|
|
26
27
|
text: string;
|
|
27
28
|
mediaUrl?: string;
|
|
29
|
+
/** Message ID — used to derive E2E decryption context as "{messageId}:media" */
|
|
30
|
+
messageId?: string;
|
|
31
|
+
/** Whether this message was E2E encrypted (media binary may also be encrypted) */
|
|
32
|
+
encrypted?: boolean;
|
|
28
33
|
a2ui?: string;
|
|
29
34
|
className?: string;
|
|
30
35
|
isStreaming?: boolean;
|
|
@@ -938,6 +943,8 @@ function ActionBlockPlaceholder() {
|
|
|
938
943
|
export function MessageContent({
|
|
939
944
|
text,
|
|
940
945
|
mediaUrl,
|
|
946
|
+
messageId,
|
|
947
|
+
encrypted,
|
|
941
948
|
a2ui,
|
|
942
949
|
className = "",
|
|
943
950
|
isStreaming,
|
|
@@ -965,16 +972,18 @@ export function MessageContent({
|
|
|
965
972
|
{/* Media preview */}
|
|
966
973
|
{mediaUrl && (
|
|
967
974
|
<div className="mb-2">
|
|
968
|
-
<MediaPreview url={mediaUrl} />
|
|
975
|
+
<MediaPreview url={mediaUrl} mediaContextId={encrypted && messageId ? `${messageId}:media` : undefined} />
|
|
969
976
|
</div>
|
|
970
977
|
)}
|
|
971
978
|
|
|
972
979
|
{/* Markdown text with enhanced rendering */}
|
|
973
980
|
{processedText ? (
|
|
974
981
|
<div
|
|
975
|
-
className="prose prose-sm max-w-none prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-pre:my-0 prose-code:before:content-none prose-code:after:content-none prose-headings:my-2"
|
|
982
|
+
className="prose prose-sm max-w-none overflow-hidden prose-p:my-1 prose-ul:my-1 prose-ol:my-1 prose-pre:my-0 prose-code:before:content-none prose-code:after:content-none prose-headings:my-2"
|
|
976
983
|
style={{
|
|
977
984
|
color: "var(--text-primary)",
|
|
985
|
+
overflowWrap: "break-word",
|
|
986
|
+
wordBreak: "break-word",
|
|
978
987
|
"--tw-prose-headings": "var(--text-primary)",
|
|
979
988
|
"--tw-prose-bold": "var(--text-primary)",
|
|
980
989
|
"--tw-prose-code": "var(--code-text)",
|
|
@@ -1007,9 +1016,62 @@ export function MessageContent({
|
|
|
1007
1016
|
// Media preview — handles images, audio, video, and file downloads
|
|
1008
1017
|
// ---------------------------------------------------------------------------
|
|
1009
1018
|
|
|
1010
|
-
|
|
1019
|
+
/**
|
|
1020
|
+
* MediaPreview — renders images, audio, video, and files.
|
|
1021
|
+
* If mediaContextId is provided and E2E key is available, fetches the media,
|
|
1022
|
+
* decrypts it client-side, and renders a local object URL.
|
|
1023
|
+
*/
|
|
1024
|
+
function MediaPreview({ url, mediaContextId }: { url: string; mediaContextId?: string }) {
|
|
1025
|
+
const [decryptedUrl, setDecryptedUrl] = useState<string | null>(null);
|
|
1026
|
+
const [decrypting, setDecrypting] = useState(false);
|
|
1027
|
+
|
|
1028
|
+
useEffect(() => {
|
|
1029
|
+
if (!mediaContextId || !E2eService.hasKey()) {
|
|
1030
|
+
setDecryptedUrl(null);
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
let cancelled = false;
|
|
1035
|
+
setDecrypting(true);
|
|
1036
|
+
|
|
1037
|
+
(async () => {
|
|
1038
|
+
try {
|
|
1039
|
+
const res = await fetch(url);
|
|
1040
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
1041
|
+
const encrypted = new Uint8Array(await res.arrayBuffer());
|
|
1042
|
+
const decrypted = await E2eService.decryptMedia(encrypted, mediaContextId);
|
|
1043
|
+
if (!cancelled) {
|
|
1044
|
+
const blob = new Blob([decrypted.buffer.slice(0) as ArrayBuffer]);
|
|
1045
|
+
setDecryptedUrl(URL.createObjectURL(blob));
|
|
1046
|
+
}
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
console.warn("[E2E] Media decryption failed, falling back to direct URL:", err);
|
|
1049
|
+
if (!cancelled) setDecryptedUrl(null);
|
|
1050
|
+
} finally {
|
|
1051
|
+
if (!cancelled) setDecrypting(false);
|
|
1052
|
+
}
|
|
1053
|
+
})();
|
|
1054
|
+
|
|
1055
|
+
return () => {
|
|
1056
|
+
cancelled = true;
|
|
1057
|
+
if (decryptedUrl) URL.revokeObjectURL(decryptedUrl);
|
|
1058
|
+
};
|
|
1059
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1060
|
+
}, [url, mediaContextId]);
|
|
1061
|
+
|
|
1062
|
+
// Use decrypted URL if available, otherwise fall back to direct URL
|
|
1063
|
+
const effectiveUrl = decryptedUrl || url;
|
|
1011
1064
|
const ext = url.split(".").pop()?.toLowerCase().split("?")[0] ?? "";
|
|
1012
1065
|
|
|
1066
|
+
if (decrypting) {
|
|
1067
|
+
return (
|
|
1068
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-md max-w-[360px]"
|
|
1069
|
+
style={{ background: "var(--bg-hover)", border: "1px solid var(--border)" }}>
|
|
1070
|
+
<span className="text-caption" style={{ color: "var(--text-muted)" }}>Decrypting media...</span>
|
|
1071
|
+
</div>
|
|
1072
|
+
);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1013
1075
|
// Audio
|
|
1014
1076
|
if (["mp3", "wav", "ogg", "m4a", "aac", "webm"].includes(ext)) {
|
|
1015
1077
|
return (
|
|
@@ -1021,7 +1083,7 @@ function MediaPreview({ url }: { url: string }) {
|
|
|
1021
1083
|
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
|
|
1022
1084
|
</svg>
|
|
1023
1085
|
<audio controls className="flex-1 h-8" style={{ maxWidth: 280 }}>
|
|
1024
|
-
<source src={
|
|
1086
|
+
<source src={effectiveUrl} />
|
|
1025
1087
|
</audio>
|
|
1026
1088
|
</div>
|
|
1027
1089
|
);
|
|
@@ -1035,7 +1097,7 @@ function MediaPreview({ url }: { url: string }) {
|
|
|
1035
1097
|
className="max-w-[360px] max-h-64 rounded-md"
|
|
1036
1098
|
style={{ border: "1px solid var(--border)" }}
|
|
1037
1099
|
>
|
|
1038
|
-
<source src={
|
|
1100
|
+
<source src={effectiveUrl} />
|
|
1039
1101
|
</video>
|
|
1040
1102
|
);
|
|
1041
1103
|
}
|
|
@@ -1045,7 +1107,7 @@ function MediaPreview({ url }: { url: string }) {
|
|
|
1045
1107
|
const filename = url.split("/").pop()?.split("?")[0] ?? "file";
|
|
1046
1108
|
return (
|
|
1047
1109
|
<a
|
|
1048
|
-
href={
|
|
1110
|
+
href={effectiveUrl}
|
|
1049
1111
|
target="_blank"
|
|
1050
1112
|
rel="noopener noreferrer"
|
|
1051
1113
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md max-w-[360px] hover:opacity-90 transition-opacity"
|
|
@@ -1072,11 +1134,11 @@ function MediaPreview({ url }: { url: string }) {
|
|
|
1072
1134
|
// Default: image
|
|
1073
1135
|
return (
|
|
1074
1136
|
<img
|
|
1075
|
-
src={
|
|
1137
|
+
src={effectiveUrl}
|
|
1076
1138
|
alt=""
|
|
1077
1139
|
className="max-w-[360px] max-h-64 rounded-md object-contain cursor-pointer hover:opacity-90 transition-opacity"
|
|
1078
1140
|
style={{ border: "1px solid var(--border)" }}
|
|
1079
|
-
onClick={() => window.open(
|
|
1141
|
+
onClick={() => window.open(effectiveUrl, "_blank")}
|
|
1080
1142
|
/>
|
|
1081
1143
|
);
|
|
1082
1144
|
}
|