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.
Files changed (45) hide show
  1. package/README.md +62 -22
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +4 -2
  4. package/packages/api/src/do/connection-do.ts +37 -9
  5. package/packages/api/src/index.ts +29 -7
  6. package/packages/api/src/routes/auth.ts +4 -1
  7. package/packages/api/src/routes/setup.ts +2 -0
  8. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/accounts.js +1 -0
  10. package/packages/plugin/dist/src/accounts.js.map +1 -1
  11. package/packages/plugin/dist/src/channel.d.ts +1 -0
  12. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  13. package/packages/plugin/dist/src/channel.js +180 -13
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/dist/src/types.d.ts +16 -0
  16. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  17. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  18. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/ws-client.js +18 -3
  20. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  21. package/packages/plugin/package.json +3 -2
  22. package/packages/web/dist/architecture.png +0 -0
  23. package/packages/web/dist/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  24. package/packages/web/dist/assets/{index-BST9bfvT.css → index-B1sFqYiM.css} +1 -1
  25. package/packages/web/dist/assets/index-C-FpELeN.js +1497 -0
  26. package/packages/web/dist/index.html +2 -2
  27. package/packages/web/package.json +1 -0
  28. package/packages/web/src/App.tsx +53 -9
  29. package/packages/web/src/analytics.ts +57 -0
  30. package/packages/web/src/api.ts +4 -0
  31. package/packages/web/src/components/ConnectionSettings.tsx +3 -1
  32. package/packages/web/src/components/E2ESettings.tsx +146 -0
  33. package/packages/web/src/components/IconRail.tsx +1 -12
  34. package/packages/web/src/components/LoginPage.tsx +19 -3
  35. package/packages/web/src/components/OnboardingPage.tsx +199 -5
  36. package/packages/web/src/e2e.ts +146 -0
  37. package/packages/web/src/main.tsx +3 -0
  38. package/packages/web/src/store.ts +4 -3
  39. package/packages/web/src/ws.ts +79 -4
  40. package/scripts/dev.sh +5 -5
  41. package/scripts/test-e2e-chat.ts +97 -0
  42. package/scripts/test-e2e-live.ts +194 -0
  43. package/scripts/verify-e2e-db.ts +48 -0
  44. package/scripts/verify-e2e.ts +56 -0
  45. 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-Da18EnTa.js"></script>
32
- <link rel="stylesheet" crossorigin href="/assets/index-BST9bfvT.css">
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>
@@ -11,6 +11,7 @@
11
11
  "dependencies": {
12
12
  "@tailwindcss/typography": "^0.5.19",
13
13
  "firebase": "^12.9.0",
14
+ "e2e-crypto": "*",
14
15
  "react": "^19.0.0",
15
16
  "react-dom": "^19.0.0",
16
17
  "react-markdown": "^10.1.0",
@@ -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
- if (user.settings?.defaultModel) {
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 (!stale) {
368
- dispatch({ type: "SET_MESSAGES", messages, replyCounts });
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
+ }
@@ -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="w-8 h-8" />
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 && (