botschat 0.1.6 → 0.1.8
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 +62 -22
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +4 -2
- package/packages/api/src/do/connection-do.ts +37 -9
- package/packages/api/src/index.ts +29 -7
- package/packages/api/src/routes/auth.ts +4 -1
- package/packages/api/src/routes/setup.ts +2 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +180 -13
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +18 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +3 -2
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/packages/web/dist/assets/{index-BST9bfvT.css → index-B1sFqYiM.css} +1 -1
- package/packages/web/dist/assets/index-C-FpELeN.js +1497 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +53 -9
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +4 -0
- package/packages/web/src/components/ConnectionSettings.tsx +3 -1
- package/packages/web/src/components/E2ESettings.tsx +146 -0
- package/packages/web/src/components/IconRail.tsx +1 -12
- package/packages/web/src/components/LoginPage.tsx +19 -3
- package/packages/web/src/components/OnboardingPage.tsx +199 -5
- package/packages/web/src/e2e.ts +146 -0
- package/packages/web/src/main.tsx +3 -0
- package/packages/web/src/store.ts +4 -3
- package/packages/web/src/ws.ts +79 -4
- package/scripts/dev.sh +5 -5
- package/scripts/test-e2e-chat.ts +97 -0
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/packages/web/dist/assets/index-Da18EnTa.js +0 -851
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
<link href="https://fonts.googleapis.com/css2?family=Lato:wght@400;700&family=Noto+Sans+SC:wght@400;700&display=swap" rel="stylesheet" />
|
|
29
29
|
|
|
30
30
|
<title>BotsChat</title>
|
|
31
|
-
<script type="module" crossorigin src="/assets/index-
|
|
32
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
31
|
+
<script type="module" crossorigin src="/assets/index-C-FpELeN.js"></script>
|
|
32
|
+
<link rel="stylesheet" crossorigin href="/assets/index-B1sFqYiM.css">
|
|
33
33
|
</head>
|
|
34
34
|
<body>
|
|
35
35
|
<div id="root"></div>
|
package/packages/web/src/App.tsx
CHANGED
|
@@ -20,6 +20,7 @@ import { JobList } from "./components/JobList";
|
|
|
20
20
|
import { LoginPage } from "./components/LoginPage";
|
|
21
21
|
import { OnboardingPage } from "./components/OnboardingPage";
|
|
22
22
|
import { ConnectionSettings } from "./components/ConnectionSettings";
|
|
23
|
+
import { E2ESettings } from "./components/E2ESettings";
|
|
23
24
|
import { DebugLogPanel } from "./components/DebugLogPanel";
|
|
24
25
|
import { CronSidebar } from "./components/CronSidebar";
|
|
25
26
|
import { CronDetail } from "./components/CronDetail";
|
|
@@ -27,6 +28,8 @@ import { ResizeHandle } from "./components/ResizeHandle";
|
|
|
27
28
|
import { useIsMobile } from "./hooks/useIsMobile";
|
|
28
29
|
import { MobileLayout } from "./components/MobileLayout";
|
|
29
30
|
import { dlog } from "./debug-log";
|
|
31
|
+
import { E2eService } from "./e2e";
|
|
32
|
+
import { gtagPageView } from "./analytics";
|
|
30
33
|
|
|
31
34
|
export default function App() {
|
|
32
35
|
const [state, dispatch] = useReducer(appReducer, initialState, (init): AppState => {
|
|
@@ -44,11 +47,17 @@ export default function App() {
|
|
|
44
47
|
const creatingGeneralRef = useRef(false);
|
|
45
48
|
|
|
46
49
|
const [showSettings, setShowSettings] = useState(false);
|
|
47
|
-
const [settingsTab, setSettingsTab] = useState<"general" | "connection">("general");
|
|
50
|
+
const [settingsTab, setSettingsTab] = useState<"general" | "connection" | "security">("general");
|
|
48
51
|
|
|
49
52
|
// Track whether the initial channels fetch has completed (prevents onboarding flash)
|
|
50
53
|
const [channelsLoadedOnce, setChannelsLoadedOnce] = useState(false);
|
|
51
54
|
|
|
55
|
+
// Track E2E key readiness — when key becomes available, re-decrypt messages
|
|
56
|
+
const [e2eReady, setE2eReady] = useState(E2eService.hasKey());
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
return E2eService.subscribe(() => setE2eReady(E2eService.hasKey()));
|
|
59
|
+
}, []);
|
|
60
|
+
|
|
52
61
|
// Responsive layout hooks (must be called unconditionally)
|
|
53
62
|
const isMobile = useIsMobile();
|
|
54
63
|
const mainLayout = useDefaultLayout({ id: "botschat-main" });
|
|
@@ -85,6 +94,11 @@ export default function App() {
|
|
|
85
94
|
localStorage.setItem("botschat_active_view", state.activeView);
|
|
86
95
|
}, [state.activeView]);
|
|
87
96
|
|
|
97
|
+
// Google Analytics: track virtual page views for SPA tabs
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
gtagPageView(state.activeView);
|
|
100
|
+
}, [state.activeView]);
|
|
101
|
+
|
|
88
102
|
// Persist selected cron task for automations view
|
|
89
103
|
useEffect(() => {
|
|
90
104
|
if (state.selectedCronTaskId) {
|
|
@@ -116,9 +130,7 @@ export default function App() {
|
|
|
116
130
|
.then((user) => {
|
|
117
131
|
dlog.info("Auth", `Logged in as ${user.email} (${user.id})`);
|
|
118
132
|
dispatch({ type: "SET_USER", user });
|
|
119
|
-
|
|
120
|
-
dispatch({ type: "SET_DEFAULT_MODEL", model: user.settings.defaultModel });
|
|
121
|
-
}
|
|
133
|
+
// defaultModel comes from plugin via connection.status, not from user.settings
|
|
122
134
|
})
|
|
123
135
|
.catch((err) => {
|
|
124
136
|
dlog.warn("Auth", `Auto-login failed: ${err}`);
|
|
@@ -361,18 +373,35 @@ export default function App() {
|
|
|
361
373
|
let stale = false;
|
|
362
374
|
messagesApi
|
|
363
375
|
.list(state.user.id, state.selectedSessionKey)
|
|
364
|
-
.then(({ messages, replyCounts }) => {
|
|
376
|
+
.then(async ({ messages, replyCounts }) => {
|
|
365
377
|
// Guard against stale responses when the user rapidly switches channels:
|
|
366
378
|
// the cleanup function sets `stale = true` before the new effect runs.
|
|
367
|
-
if (
|
|
368
|
-
|
|
369
|
-
|
|
379
|
+
if (stale) return;
|
|
380
|
+
|
|
381
|
+
// Decrypt history if possible
|
|
382
|
+
const decryptedMessages = await Promise.all(messages.map(async (m) => {
|
|
383
|
+
if (m.encrypted && E2eService.hasKey()) {
|
|
384
|
+
try {
|
|
385
|
+
// Use message ID as context ID (nonce source)
|
|
386
|
+
const plaintext = await E2eService.decrypt(m.text, m.id);
|
|
387
|
+
return { ...m, text: plaintext, isEncryptedLocked: false };
|
|
388
|
+
} catch (err) {
|
|
389
|
+
console.warn(`Failed to decrypt message ${m.id}`, err);
|
|
390
|
+
return { ...m, isEncryptedLocked: true };
|
|
391
|
+
}
|
|
392
|
+
} else if (m.encrypted) {
|
|
393
|
+
return { ...m, isEncryptedLocked: true };
|
|
394
|
+
}
|
|
395
|
+
return m;
|
|
396
|
+
}));
|
|
397
|
+
|
|
398
|
+
dispatch({ type: "SET_MESSAGES", messages: decryptedMessages as ChatMessage[], replyCounts });
|
|
370
399
|
})
|
|
371
400
|
.catch((err) => {
|
|
372
401
|
console.error("Failed to load message history:", err);
|
|
373
402
|
});
|
|
374
403
|
return () => { stale = true; };
|
|
375
|
-
}, [state.user, state.selectedSessionKey]);
|
|
404
|
+
}, [state.user, state.selectedSessionKey, e2eReady]);
|
|
376
405
|
|
|
377
406
|
// Keep a ref to state for use in WS handler (avoids stale closures)
|
|
378
407
|
const stateRef = useRef(state);
|
|
@@ -940,6 +969,17 @@ export default function App() {
|
|
|
940
969
|
>
|
|
941
970
|
Connection
|
|
942
971
|
</button>
|
|
972
|
+
<button
|
|
973
|
+
className="pb-2 text-caption font-bold transition-colors"
|
|
974
|
+
style={{
|
|
975
|
+
color: settingsTab === "security" ? "var(--text-primary)" : "var(--text-muted)",
|
|
976
|
+
borderBottom: settingsTab === "security" ? "2px solid var(--bg-active)" : "2px solid transparent",
|
|
977
|
+
marginBottom: "-1px",
|
|
978
|
+
}}
|
|
979
|
+
onClick={() => setSettingsTab("security")}
|
|
980
|
+
>
|
|
981
|
+
Security
|
|
982
|
+
</button>
|
|
943
983
|
</div>
|
|
944
984
|
|
|
945
985
|
{/* Tab content — scrollable */}
|
|
@@ -987,6 +1027,10 @@ export default function App() {
|
|
|
987
1027
|
{settingsTab === "connection" && (
|
|
988
1028
|
<ConnectionSettings />
|
|
989
1029
|
)}
|
|
1030
|
+
|
|
1031
|
+
{settingsTab === "security" && (
|
|
1032
|
+
<E2ESettings />
|
|
1033
|
+
)}
|
|
990
1034
|
</div>
|
|
991
1035
|
|
|
992
1036
|
<div
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Analytics (GA4). Only loads when VITE_GA_MEASUREMENT_ID is set (e.g. in .env.production).
|
|
3
|
+
* Set VITE_GA_MEASUREMENT_ID to your GA4 Measurement ID (e.g. G-XXXXXXXXXX) to enable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
declare global {
|
|
7
|
+
interface Window {
|
|
8
|
+
dataLayer: unknown[];
|
|
9
|
+
gtag?: (...args: unknown[]) => void;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MEASUREMENT_ID = import.meta.env.VITE_GA_MEASUREMENT_ID as string | undefined;
|
|
14
|
+
|
|
15
|
+
function loadGtag(): boolean {
|
|
16
|
+
if (!MEASUREMENT_ID || typeof window === "undefined") return false;
|
|
17
|
+
if (window.gtag) return true;
|
|
18
|
+
|
|
19
|
+
window.dataLayer = window.dataLayer || [];
|
|
20
|
+
window.gtag = function gtag() {
|
|
21
|
+
window.dataLayer.push(arguments);
|
|
22
|
+
};
|
|
23
|
+
window.gtag("js", new Date());
|
|
24
|
+
window.gtag("config", MEASUREMENT_ID);
|
|
25
|
+
|
|
26
|
+
const script = document.createElement("script");
|
|
27
|
+
script.async = true;
|
|
28
|
+
script.src = `https://www.googletagmanager.com/gtag/js?id=${MEASUREMENT_ID}`;
|
|
29
|
+
document.head.appendChild(script);
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let initialized = false;
|
|
34
|
+
|
|
35
|
+
export function initAnalytics(): void {
|
|
36
|
+
if (initialized) return;
|
|
37
|
+
initialized = loadGtag();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function isAnalyticsEnabled(): boolean {
|
|
41
|
+
return initialized && !!window.gtag;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Send a page_view or custom event to GA4. Use after route/view changes in the SPA.
|
|
46
|
+
*/
|
|
47
|
+
export function gtagEvent(name: string, params?: Record<string, string>): void {
|
|
48
|
+
if (!MEASUREMENT_ID || !window.gtag) return;
|
|
49
|
+
window.gtag("event", name, params);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Track a virtual page view (e.g. "Messages" / "Automations" tab).
|
|
54
|
+
*/
|
|
55
|
+
export function gtagPageView(page: string): void {
|
|
56
|
+
gtagEvent("page_view", { page_path: `/${page}`, page_title: page });
|
|
57
|
+
}
|
package/packages/web/src/api.ts
CHANGED
|
@@ -227,6 +227,8 @@ export type TaskScanEntry = {
|
|
|
227
227
|
instructions: string;
|
|
228
228
|
model: string;
|
|
229
229
|
enabled: boolean;
|
|
230
|
+
encrypted?: boolean;
|
|
231
|
+
iv?: string;
|
|
230
232
|
};
|
|
231
233
|
|
|
232
234
|
export const tasksApi = {
|
|
@@ -258,6 +260,7 @@ export type Job = {
|
|
|
258
260
|
durationMs: number | null;
|
|
259
261
|
summary: string;
|
|
260
262
|
time: string;
|
|
263
|
+
encrypted?: boolean;
|
|
261
264
|
};
|
|
262
265
|
|
|
263
266
|
export const jobsApi = {
|
|
@@ -276,6 +279,7 @@ export type MessageRecord = {
|
|
|
276
279
|
mediaUrl?: string;
|
|
277
280
|
a2ui?: string;
|
|
278
281
|
threadId?: string;
|
|
282
|
+
encrypted?: boolean;
|
|
279
283
|
};
|
|
280
284
|
|
|
281
285
|
export const messagesApi = {
|
|
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback } from "react";
|
|
|
2
2
|
import { pairingApi, setupApi, type PairingToken } from "../api";
|
|
3
3
|
import { useAppState } from "../store";
|
|
4
4
|
import { dlog } from "../debug-log";
|
|
5
|
+
import { E2eService } from "../e2e";
|
|
5
6
|
|
|
6
7
|
/** Clipboard copy button with feedback */
|
|
7
8
|
function CopyButton({ text }: { text: string }) {
|
|
@@ -168,10 +169,11 @@ export function ConnectionSettings() {
|
|
|
168
169
|
// We never display full token values from the GET list (security: they are masked).
|
|
169
170
|
const commandToken = freshToken?.token ?? null;
|
|
170
171
|
|
|
172
|
+
const e2ePwd = E2eService.getPassword();
|
|
171
173
|
const setupCommand = commandToken
|
|
172
174
|
? `openclaw plugins install @botschat/botschat && \\
|
|
173
175
|
openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
|
|
174
|
-
openclaw config set channels.botschat.pairingToken ${commandToken} &&
|
|
176
|
+
openclaw config set channels.botschat.pairingToken ${commandToken} && \\${e2ePwd ? `\nopenclaw config set channels.botschat.e2ePassword "${e2ePwd}" && \\` : ""}
|
|
175
177
|
openclaw config set channels.botschat.enabled true && \\
|
|
176
178
|
openclaw gateway restart`
|
|
177
179
|
: null;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { E2eService } from "../e2e";
|
|
3
|
+
import { AppStateContext } from "../store";
|
|
4
|
+
|
|
5
|
+
export function E2ESettings() {
|
|
6
|
+
const { user } = React.useContext(AppStateContext);
|
|
7
|
+
const [hasKey, setHasKey] = useState(E2eService.hasKey());
|
|
8
|
+
const [password, setPassword] = useState("");
|
|
9
|
+
const [remember, setRemember] = useState(false);
|
|
10
|
+
const [busy, setBusy] = useState(false);
|
|
11
|
+
const [error, setError] = useState<string | null>(null);
|
|
12
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
13
|
+
|
|
14
|
+
// Subscribe to E2eService changes
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
return E2eService.subscribe(() => {
|
|
17
|
+
setHasKey(E2eService.hasKey());
|
|
18
|
+
});
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
const handleUnlock = async () => {
|
|
22
|
+
if (!password || !user) return;
|
|
23
|
+
setBusy(true);
|
|
24
|
+
setError(null);
|
|
25
|
+
try {
|
|
26
|
+
await E2eService.setPassword(password, user.id, remember);
|
|
27
|
+
setPassword(""); // Clear input on success
|
|
28
|
+
} catch (err) {
|
|
29
|
+
setError("Failed to set password. check logs.");
|
|
30
|
+
} finally {
|
|
31
|
+
setBusy(false);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const handleLock = () => {
|
|
36
|
+
E2eService.clear();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-6">
|
|
41
|
+
<div>
|
|
42
|
+
<h3 className="text-h3 font-bold mb-2" style={{ color: "var(--text-primary)" }}>
|
|
43
|
+
End-to-End Encryption
|
|
44
|
+
</h3>
|
|
45
|
+
<p className="text-body" style={{ color: "var(--text-muted)" }}>
|
|
46
|
+
Your messages and tasks are encrypted before leaving your device.
|
|
47
|
+
Only your device (with this password) can decrypt them.
|
|
48
|
+
</p>
|
|
49
|
+
</div>
|
|
50
|
+
|
|
51
|
+
<div className="p-4 rounded-md border" style={{ borderColor: "var(--border)", background: hasKey ? "rgba(0, 255, 0, 0.05)" : "rgba(255, 0, 0, 0.05)" }}>
|
|
52
|
+
<div className="flex items-center justify-between mb-4">
|
|
53
|
+
<span className="font-bold flex items-center gap-2" style={{ color: hasKey ? "var(--success)" : "var(--error)" }}>
|
|
54
|
+
{hasKey ? (
|
|
55
|
+
<>
|
|
56
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
|
|
57
|
+
Active (Unlocked)
|
|
58
|
+
</>
|
|
59
|
+
) : (
|
|
60
|
+
<>
|
|
61
|
+
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" /></svg>
|
|
62
|
+
Inactive (Locked)
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
{hasKey && (
|
|
67
|
+
<button onClick={handleLock} className="text-caption font-bold hover:underline" style={{ color: "var(--accent-red, #e53e3e)" }}>
|
|
68
|
+
Lock / Clear Key
|
|
69
|
+
</button>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
{!hasKey && (
|
|
74
|
+
<div className="space-y-4">
|
|
75
|
+
<div>
|
|
76
|
+
<label className="block text-caption font-bold mb-1" style={{ color: "var(--text-secondary)" }}>E2E Password</label>
|
|
77
|
+
<div className="relative">
|
|
78
|
+
<input
|
|
79
|
+
type={showPassword ? "text" : "password"}
|
|
80
|
+
value={password}
|
|
81
|
+
onChange={e => setPassword(e.target.value)}
|
|
82
|
+
className="w-full px-3 py-2 pr-10 rounded border"
|
|
83
|
+
style={{ background: "var(--bg-input)", borderColor: "var(--border)", color: "var(--text-primary)" }}
|
|
84
|
+
placeholder="Enter your encryption password"
|
|
85
|
+
/>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
89
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1"
|
|
90
|
+
style={{ color: "var(--text-muted)" }}
|
|
91
|
+
tabIndex={-1}
|
|
92
|
+
>
|
|
93
|
+
{showPassword ? (
|
|
94
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
95
|
+
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
|
|
96
|
+
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
|
|
97
|
+
<path d="M14.12 14.12a3 3 0 1 1-4.24-4.24"/>
|
|
98
|
+
<line x1="1" y1="1" x2="23" y2="23"/>
|
|
99
|
+
</svg>
|
|
100
|
+
) : (
|
|
101
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
102
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
|
|
103
|
+
<circle cx="12" cy="12" r="3"/>
|
|
104
|
+
</svg>
|
|
105
|
+
)}
|
|
106
|
+
</button>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<input
|
|
112
|
+
type="checkbox"
|
|
113
|
+
id="remember-e2e"
|
|
114
|
+
checked={remember}
|
|
115
|
+
onChange={e => setRemember(e.target.checked)}
|
|
116
|
+
/>
|
|
117
|
+
<label htmlFor="remember-e2e" className="text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
118
|
+
Remember on this device
|
|
119
|
+
</label>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{error && <p className="text-caption text-red-500">{error}</p>}
|
|
123
|
+
|
|
124
|
+
<button
|
|
125
|
+
onClick={handleUnlock}
|
|
126
|
+
disabled={!password || busy}
|
|
127
|
+
className="px-4 py-2 rounded font-bold w-full"
|
|
128
|
+
style={{ background: "var(--bg-active, #6366f1)", color: "#fff", opacity: (!password || busy) ? 0.5 : 1 }}
|
|
129
|
+
>
|
|
130
|
+
{busy ? "Deriving Key..." : "Unlock / Set Password"}
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
137
|
+
<p className="font-bold text-red-400 mb-1">Warning:</p>
|
|
138
|
+
<ul className="list-disc ml-5 space-y-1">
|
|
139
|
+
<li>If you lose this password, your encrypted history is lost forever.</li>
|
|
140
|
+
<li>We do not store this password on our servers.</li>
|
|
141
|
+
<li>You must use the same password on all devices to access your history.</li>
|
|
142
|
+
</ul>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -40,22 +40,11 @@ export function IconRail({ onToggleTheme, onOpenSettings, theme }: IconRailProps
|
|
|
40
40
|
className="w-8 h-8 rounded-lg flex items-center justify-center overflow-hidden hover:rounded-xl transition-all"
|
|
41
41
|
title="BotsChat"
|
|
42
42
|
>
|
|
43
|
-
<img src="/botschat-logo.png" alt="BotsChat" className=
|
|
43
|
+
<img src="/botschat-logo.png" alt="BotsChat" className={`w-8 h-8 ${theme === "dark" ? "invert" : ""}`} />
|
|
44
44
|
</button>
|
|
45
45
|
|
|
46
46
|
<div className="w-7 border-t my-1" style={{ borderColor: "var(--sidebar-divider)" }} />
|
|
47
47
|
|
|
48
|
-
{/* Home */}
|
|
49
|
-
<RailIcon
|
|
50
|
-
label="Home"
|
|
51
|
-
active={false}
|
|
52
|
-
icon={
|
|
53
|
-
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
54
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955a1.126 1.126 0 011.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
|
55
|
-
</svg>
|
|
56
|
-
}
|
|
57
|
-
/>
|
|
58
|
-
|
|
59
48
|
{/* Messages */}
|
|
60
49
|
<RailIcon
|
|
61
50
|
label="Messages"
|
|
@@ -49,6 +49,8 @@ export function LoginPage() {
|
|
|
49
49
|
}, [firebaseEnabled]);
|
|
50
50
|
|
|
51
51
|
const emailEnabled = authConfig?.emailEnabled ?? true;
|
|
52
|
+
const configLoaded = authConfig !== null;
|
|
53
|
+
const hasAnyLoginMethod = configLoaded && (firebaseEnabled || emailEnabled);
|
|
52
54
|
|
|
53
55
|
const handleAuthSuccess = (res: { id: string; email: string; displayName?: string; token: string; refreshToken?: string }) => {
|
|
54
56
|
setToken(res.token);
|
|
@@ -148,8 +150,22 @@ export function LoginPage() {
|
|
|
148
150
|
: "Sign in"}
|
|
149
151
|
</h2>
|
|
150
152
|
|
|
153
|
+
{/* Loading: avoid showing empty card on first paint before config is loaded */}
|
|
154
|
+
{!configLoaded && (
|
|
155
|
+
<div className="py-8 text-center" style={{ color: "var(--text-muted)" }}>
|
|
156
|
+
<span className="text-body">Loading sign-in options…</span>
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
|
|
160
|
+
{/* No methods available (e.g. misconfiguration) */}
|
|
161
|
+
{configLoaded && !hasAnyLoginMethod && (
|
|
162
|
+
<div className="py-4 text-caption" style={{ color: "var(--text-secondary)" }}>
|
|
163
|
+
Sign-in is not configured. Please contact support.
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
|
|
151
167
|
{/* OAuth buttons */}
|
|
152
|
-
{firebaseEnabled && (
|
|
168
|
+
{configLoaded && firebaseEnabled && (
|
|
153
169
|
<>
|
|
154
170
|
<div className="space-y-3">
|
|
155
171
|
{/* Google */}
|
|
@@ -198,7 +214,7 @@ export function LoginPage() {
|
|
|
198
214
|
</div>
|
|
199
215
|
|
|
200
216
|
{/* Divider — only show if email login is also available */}
|
|
201
|
-
{emailEnabled && (
|
|
217
|
+
{configLoaded && emailEnabled && (
|
|
202
218
|
<div className="flex items-center gap-3 my-5">
|
|
203
219
|
<div className="flex-1 h-px" style={{ background: "var(--border)" }} />
|
|
204
220
|
<span className="text-caption" style={{ color: "var(--text-muted)" }}>
|
|
@@ -221,7 +237,7 @@ export function LoginPage() {
|
|
|
221
237
|
)}
|
|
222
238
|
|
|
223
239
|
{/* Email/password form — only in local/dev mode */}
|
|
224
|
-
{emailEnabled && (
|
|
240
|
+
{configLoaded && emailEnabled && (
|
|
225
241
|
<>
|
|
226
242
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
227
243
|
{isRegister && (
|