botschat 0.1.4 → 0.1.7

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 (66) hide show
  1. package/README.md +64 -24
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +7 -2
  4. package/packages/api/package.json +2 -1
  5. package/packages/api/src/do/connection-do.ts +162 -42
  6. package/packages/api/src/index.ts +132 -13
  7. package/packages/api/src/routes/auth.ts +127 -30
  8. package/packages/api/src/routes/pairing.ts +14 -1
  9. package/packages/api/src/routes/setup.ts +72 -24
  10. package/packages/api/src/routes/upload.ts +12 -8
  11. package/packages/api/src/utils/auth.ts +212 -43
  12. package/packages/api/src/utils/id.ts +30 -14
  13. package/packages/api/src/utils/rate-limit.ts +73 -0
  14. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  15. package/packages/plugin/dist/src/accounts.js +1 -0
  16. package/packages/plugin/dist/src/accounts.js.map +1 -1
  17. package/packages/plugin/dist/src/channel.d.ts +1 -0
  18. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/channel.js +151 -9
  20. package/packages/plugin/dist/src/channel.js.map +1 -1
  21. package/packages/plugin/dist/src/types.d.ts +16 -0
  22. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  23. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  24. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  25. package/packages/plugin/dist/src/ws-client.js +14 -3
  26. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  27. package/packages/plugin/package.json +4 -3
  28. package/packages/web/dist/architecture.png +0 -0
  29. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  30. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
  31. package/packages/web/dist/botschat-icon.svg +4 -0
  32. package/packages/web/dist/index.html +23 -3
  33. package/packages/web/dist/manifest.json +24 -0
  34. package/packages/web/dist/sw.js +40 -0
  35. package/packages/web/index.html +21 -1
  36. package/packages/web/package.json +1 -0
  37. package/packages/web/src/App.tsx +286 -103
  38. package/packages/web/src/analytics.ts +57 -0
  39. package/packages/web/src/api.ts +67 -3
  40. package/packages/web/src/components/ChatWindow.tsx +11 -11
  41. package/packages/web/src/components/ConnectionSettings.tsx +477 -0
  42. package/packages/web/src/components/CronDetail.tsx +475 -235
  43. package/packages/web/src/components/CronSidebar.tsx +1 -1
  44. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  45. package/packages/web/src/components/E2ESettings.tsx +122 -0
  46. package/packages/web/src/components/IconRail.tsx +56 -27
  47. package/packages/web/src/components/JobList.tsx +2 -6
  48. package/packages/web/src/components/LoginPage.tsx +143 -104
  49. package/packages/web/src/components/MobileLayout.tsx +480 -0
  50. package/packages/web/src/components/OnboardingPage.tsx +159 -21
  51. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  52. package/packages/web/src/components/Sidebar.tsx +1 -1
  53. package/packages/web/src/components/TaskBar.tsx +2 -2
  54. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  55. package/packages/web/src/e2e.ts +133 -0
  56. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  57. package/packages/web/src/index.css +59 -0
  58. package/packages/web/src/main.tsx +12 -0
  59. package/packages/web/src/store.ts +16 -8
  60. package/packages/web/src/ws.ts +78 -4
  61. package/scripts/dev.sh +16 -16
  62. package/scripts/test-e2e-live.ts +194 -0
  63. package/scripts/verify-e2e-db.ts +48 -0
  64. package/scripts/verify-e2e.ts +56 -0
  65. package/wrangler.toml +3 -1
  66. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
@@ -5,6 +5,7 @@ import { dlog } from "./debug-log";
5
5
  const API_BASE = "/api";
6
6
 
7
7
  let _token: string | null = localStorage.getItem("botschat_token");
8
+ let _refreshToken: string | null = localStorage.getItem("botschat_refresh_token");
8
9
 
9
10
  export function setToken(token: string | null) {
10
11
  _token = token;
@@ -12,10 +13,39 @@ export function setToken(token: string | null) {
12
13
  else localStorage.removeItem("botschat_token");
13
14
  }
14
15
 
16
+ export function setRefreshToken(token: string | null) {
17
+ _refreshToken = token;
18
+ if (token) localStorage.setItem("botschat_refresh_token", token);
19
+ else localStorage.removeItem("botschat_refresh_token");
20
+ }
21
+
15
22
  export function getToken(): string | null {
16
23
  return _token;
17
24
  }
18
25
 
26
+ export function getRefreshToken(): string | null {
27
+ return _refreshToken;
28
+ }
29
+
30
+ /** Try to refresh the access token using the refresh token. */
31
+ async function tryRefreshAccessToken(): Promise<boolean> {
32
+ if (!_refreshToken) return false;
33
+ try {
34
+ const res = await fetch(`${API_BASE}/auth/refresh`, {
35
+ method: "POST",
36
+ headers: { "Content-Type": "application/json" },
37
+ body: JSON.stringify({ refreshToken: _refreshToken }),
38
+ });
39
+ if (!res.ok) return false;
40
+ const data = await res.json() as { token: string };
41
+ setToken(data.token);
42
+ dlog.info("API", "Access token refreshed successfully");
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+
19
49
  async function request<T>(
20
50
  method: string,
21
51
  path: string,
@@ -44,6 +74,27 @@ async function request<T>(
44
74
  throw err;
45
75
  }
46
76
 
77
+ // Auto-refresh on 401 (expired access token)
78
+ if (res.status === 401 && _refreshToken && !path.includes("/auth/refresh")) {
79
+ const refreshed = await tryRefreshAccessToken();
80
+ if (refreshed) {
81
+ // Retry the original request with the new token
82
+ headers["Authorization"] = `Bearer ${_token}`;
83
+ try {
84
+ res = await fetch(`${API_BASE}${path}`, {
85
+ method,
86
+ headers,
87
+ body: body ? JSON.stringify(body) : undefined,
88
+ cache: "no-store",
89
+ });
90
+ } catch (err) {
91
+ const ms = Math.round(performance.now() - t0);
92
+ dlog.error("API", `✗ ${tag} — network error on retry (${ms}ms)`, String(err));
93
+ throw err;
94
+ }
95
+ }
96
+ }
97
+
47
98
  const ms = Math.round(performance.now() - t0);
48
99
 
49
100
  if (!res.ok) {
@@ -59,11 +110,19 @@ async function request<T>(
59
110
  }
60
111
 
61
112
  // ---- Auth ----
62
- export type AuthResponse = { id: string; email: string; token: string; displayName?: string };
113
+ export type AuthResponse = { id: string; email: string; token: string; refreshToken?: string; displayName?: string };
63
114
 
64
115
  export type UserSettings = { defaultModel?: string };
65
116
 
117
+ export type AuthConfig = {
118
+ emailEnabled: boolean;
119
+ googleEnabled: boolean;
120
+ githubEnabled: boolean;
121
+ };
122
+
66
123
  export const authApi = {
124
+ /** Fetch server-side auth configuration (which methods are available). */
125
+ config: () => request<AuthConfig>("GET", "/auth/config"),
67
126
  register: (email: string, password: string, displayName?: string) =>
68
127
  request<AuthResponse>("POST", "/auth/register", { email, password, displayName }),
69
128
  login: (email: string, password: string) =>
@@ -168,6 +227,8 @@ export type TaskScanEntry = {
168
227
  instructions: string;
169
228
  model: string;
170
229
  enabled: boolean;
230
+ encrypted?: boolean;
231
+ iv?: string;
171
232
  };
172
233
 
173
234
  export const tasksApi = {
@@ -175,7 +236,7 @@ export const tasksApi = {
175
236
  request<{ tasks: Task[] }>("GET", `/channels/${channelId}/tasks`),
176
237
  listAll: (kind: "background" | "adhoc" = "background") =>
177
238
  request<{ tasks: TaskWithChannel[] }>("GET", `/tasks?kind=${kind}`),
178
- /** Fetch OpenClaw-owned fields (schedule/instructions/model) cached in the DO. */
239
+ /** Fetch OpenClaw-owned fields (schedule/instructions/model) from plugin via DO (live task.scan.request, no cache). */
179
240
  scanData: () =>
180
241
  request<{ tasks: TaskScanEntry[] }>("GET", "/task-scan"),
181
242
  create: (channelId: string, data: { name: string; kind: "background" | "adhoc"; schedule?: string; instructions?: string }) =>
@@ -199,6 +260,7 @@ export type Job = {
199
260
  durationMs: number | null;
200
261
  summary: string;
201
262
  time: string;
263
+ encrypted?: boolean;
202
264
  };
203
265
 
204
266
  export const jobsApi = {
@@ -217,6 +279,7 @@ export type MessageRecord = {
217
279
  mediaUrl?: string;
218
280
  a2ui?: string;
219
281
  threadId?: string;
282
+ encrypted?: boolean;
220
283
  };
221
284
 
222
285
  export const messagesApi = {
@@ -230,7 +293,8 @@ export const messagesApi = {
230
293
  // ---- Pairing Tokens ----
231
294
  export type PairingToken = {
232
295
  id: string;
233
- token: string;
296
+ // Full token is no longer returned by the GET endpoint (security).
297
+ // Only `tokenPreview` (masked) is available after creation.
234
298
  tokenPreview: string;
235
299
  label: string | null;
236
300
  lastConnectedAt: number | null;
@@ -474,7 +474,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
474
474
 
475
475
  if (!sessionKey) {
476
476
  return (
477
- <div className="flex-1 flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
477
+ <div className="flex-1 h-full flex items-center justify-center" style={{ background: "var(--bg-surface)" }}>
478
478
  <div className="text-center">
479
479
  <svg className="w-16 h-16 mx-auto mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1} style={{ color: "var(--text-muted)" }}>
480
480
  <path strokeLinecap="round" strokeLinejoin="round" d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155" />
@@ -493,7 +493,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
493
493
  return (
494
494
  <div
495
495
  ref={dropZoneRef}
496
- className="flex-1 flex flex-col min-w-0 relative"
496
+ className="flex-1 flex flex-col min-w-0 h-full relative"
497
497
  style={{ background: "var(--bg-surface)" }}
498
498
  onDragEnter={handleDragEnter}
499
499
  onDragLeave={handleDragLeave}
@@ -519,24 +519,24 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
519
519
 
520
520
  {/* Channel header */}
521
521
  <div
522
- className="flex items-center justify-between px-5"
522
+ className="flex items-center justify-between px-3 sm:px-5 gap-2 flex-shrink-0"
523
523
  style={{
524
524
  height: 44,
525
525
  borderBottom: "1px solid var(--border)",
526
526
  }}
527
527
  >
528
- <div className="flex items-center gap-2">
529
- <span className="text-h1" style={{ color: "var(--text-primary)" }}>
528
+ <div className="flex items-center gap-2 min-w-0">
529
+ <span className="text-h1 truncate" style={{ color: "var(--text-primary)" }}>
530
530
  # {channelName}
531
531
  </span>
532
532
  {selectedAgent && !selectedAgent.isDefault && (
533
- <span className="text-caption" style={{ color: "var(--text-secondary)" }}>
533
+ <span className="text-caption hidden sm:inline flex-shrink-0" style={{ color: "var(--text-secondary)" }}>
534
534
  — custom channel
535
535
  </span>
536
536
  )}
537
537
  </div>
538
- <div className="flex items-center gap-1.5">
539
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
538
+ <div className="flex items-center gap-1.5 flex-shrink-0">
539
+ <svg className="w-3.5 h-3.5 hidden sm:block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} style={{ color: "var(--text-muted)" }}>
540
540
  <path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456z" />
541
541
  </svg>
542
542
  <ModelSelect
@@ -554,7 +554,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
554
554
  {showSessionTabs && <SessionTabs channelId={channelId} />}
555
555
 
556
556
  {/* Messages – flat-row layout */}
557
- <div className="flex-1 overflow-y-auto">
557
+ <div className="flex-1 min-h-0 overflow-y-auto">
558
558
  {state.messages.length === 0 && (
559
559
  <div className="py-12 px-5 text-center">
560
560
  <p className="text-caption" style={{ color: "var(--text-muted)" }}>
@@ -582,7 +582,7 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
582
582
  </div>
583
583
 
584
584
  {/* Composer (section 5.6) */}
585
- <div className="px-5 pb-4 pt-2">
585
+ <div className="flex-shrink-0 px-3 sm:px-5 pb-3 sm:pb-4 pt-2">
586
586
  {/* Skill buttons — sorted by recency-weighted score */}
587
587
  <div className="flex items-center gap-1.5 pb-1.5 overflow-x-auto no-scrollbar">
588
588
  {sortedSkills.map((skill) => {
@@ -744,7 +744,7 @@ function MessageRow({
744
744
 
745
745
  return (
746
746
  <div
747
- className="group relative px-5 hover:bg-[--bg-hover] transition-colors"
747
+ className="group relative px-3 sm:px-5 hover:bg-[--bg-hover] transition-colors"
748
748
  style={{ paddingTop: grouped ? 2 : 8, paddingBottom: 2 }}
749
749
  >
750
750
  <div className="flex gap-2 max-w-message">
@@ -0,0 +1,477 @@
1
+ import React, { useState, useEffect, useCallback } from "react";
2
+ import { pairingApi, setupApi, type PairingToken } from "../api";
3
+ import { useAppState } from "../store";
4
+ import { dlog } from "../debug-log";
5
+ import { E2eService } from "../e2e";
6
+
7
+ /** Clipboard copy button with feedback */
8
+ function CopyButton({ text }: { text: string }) {
9
+ const [copied, setCopied] = useState(false);
10
+
11
+ const handleCopy = async () => {
12
+ try {
13
+ await navigator.clipboard.writeText(text);
14
+ setCopied(true);
15
+ setTimeout(() => setCopied(false), 2000);
16
+ } catch {
17
+ const ta = document.createElement("textarea");
18
+ ta.value = text;
19
+ document.body.appendChild(ta);
20
+ ta.select();
21
+ document.execCommand("copy");
22
+ document.body.removeChild(ta);
23
+ setCopied(true);
24
+ setTimeout(() => setCopied(false), 2000);
25
+ }
26
+ };
27
+
28
+ return (
29
+ <button
30
+ onClick={handleCopy}
31
+ className="shrink-0 px-2.5 py-1 text-tiny font-medium rounded-sm transition-colors"
32
+ style={{
33
+ background: copied ? "var(--accent-green)" : "var(--bg-hover)",
34
+ color: copied ? "#fff" : "var(--text-secondary)",
35
+ }}
36
+ >
37
+ {copied ? "Copied!" : "Copy"}
38
+ </button>
39
+ );
40
+ }
41
+
42
+ /** Code block with copy button */
43
+ function CodeBlock({ code }: { code: string }) {
44
+ return (
45
+ <div
46
+ className="flex items-start gap-2 rounded-md px-3 py-2.5"
47
+ style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
48
+ >
49
+ <pre
50
+ className="flex-1 text-caption font-mono overflow-x-auto whitespace-pre-wrap break-all"
51
+ style={{ color: "var(--text-primary)" }}
52
+ >
53
+ {code}
54
+ </pre>
55
+ <CopyButton text={code} />
56
+ </div>
57
+ );
58
+ }
59
+
60
+ /** Relative time from a unix timestamp */
61
+ function timeAgo(unixTs: number): string {
62
+ const now = Date.now() / 1000;
63
+ const diff = now - unixTs;
64
+ if (diff < 60) return "Just now";
65
+ if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
66
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
67
+ if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`;
68
+ return new Date(unixTs * 1000).toLocaleDateString();
69
+ }
70
+
71
+ export function ConnectionSettings() {
72
+ const state = useAppState();
73
+
74
+ const [tokens, setTokens] = useState<PairingToken[]>([]);
75
+ const [loadingTokens, setLoadingTokens] = useState(true);
76
+
77
+ const [cloudUrl, setCloudUrl] = useState<string>(
78
+ typeof window !== "undefined" ? window.location.origin : "https://console.botschat.app",
79
+ );
80
+ const [cloudUrlLoopback, setCloudUrlLoopback] = useState(false);
81
+ const [cloudUrlHint, setCloudUrlHint] = useState<string | undefined>();
82
+ const [editingUrl, setEditingUrl] = useState(false);
83
+
84
+ const [showCreateToken, setShowCreateToken] = useState(false);
85
+ const [newTokenLabel, setNewTokenLabel] = useState("");
86
+ const [creatingToken, setCreatingToken] = useState(false);
87
+ /** Token value of a freshly created token (only shown once, for the user to copy). */
88
+ const [freshToken, setFreshToken] = useState<{ id: string; token: string } | null>(null);
89
+
90
+ // Fetch recommended cloudUrl from backend
91
+ useEffect(() => {
92
+ let cancelled = false;
93
+
94
+ setupApi
95
+ .cloudUrl()
96
+ .then((data) => {
97
+ if (cancelled) return;
98
+ setCloudUrl(data.cloudUrl);
99
+ setCloudUrlLoopback(data.isLoopback);
100
+ setCloudUrlHint(data.hint);
101
+ })
102
+ .catch((err) => {
103
+ dlog.warn("ConnectionSettings", `Failed to fetch cloudUrl: ${err}`);
104
+ const host = window.location.hostname;
105
+ const loopback = host === "localhost" || host.startsWith("127.");
106
+ setCloudUrlLoopback(loopback);
107
+ if (loopback) {
108
+ setCloudUrlHint(
109
+ "This URL (localhost) only works on this machine. If your OpenClaw is on a different host, replace with its LAN IP.",
110
+ );
111
+ }
112
+ });
113
+
114
+ return () => {
115
+ cancelled = true;
116
+ };
117
+ }, []);
118
+
119
+ // Fetch pairing tokens
120
+ useEffect(() => {
121
+ let cancelled = false;
122
+ setLoadingTokens(true);
123
+
124
+ pairingApi
125
+ .list()
126
+ .then(({ tokens: list }) => {
127
+ if (!cancelled) setTokens(list);
128
+ })
129
+ .catch((err) => {
130
+ dlog.error("ConnectionSettings", `Failed to list tokens: ${err}`);
131
+ })
132
+ .finally(() => {
133
+ if (!cancelled) setLoadingTokens(false);
134
+ });
135
+
136
+ return () => {
137
+ cancelled = true;
138
+ };
139
+ }, []);
140
+
141
+ const handleCreateToken = useCallback(async () => {
142
+ setCreatingToken(true);
143
+ try {
144
+ const result = await pairingApi.create(newTokenLabel.trim() || undefined);
145
+ setFreshToken({ id: result.id, token: result.token });
146
+ // Refresh token list
147
+ const { tokens: refreshed } = await pairingApi.list();
148
+ setTokens(refreshed);
149
+ setNewTokenLabel("");
150
+ setShowCreateToken(false);
151
+ } catch (err) {
152
+ dlog.error("ConnectionSettings", `Failed to create token: ${err}`);
153
+ } finally {
154
+ setCreatingToken(false);
155
+ }
156
+ }, [newTokenLabel]);
157
+
158
+ const handleRevokeToken = useCallback(async (tokenId: string) => {
159
+ try {
160
+ await pairingApi.delete(tokenId);
161
+ setTokens((prev) => prev.filter((t) => t.id !== tokenId));
162
+ if (freshToken?.id === tokenId) setFreshToken(null);
163
+ } catch (err) {
164
+ dlog.error("ConnectionSettings", `Failed to revoke token: ${err}`);
165
+ }
166
+ }, [freshToken]);
167
+
168
+ // The token to use in the setup command: only the freshly created token (shown once).
169
+ // We never display full token values from the GET list (security: they are masked).
170
+ const commandToken = freshToken?.token ?? null;
171
+
172
+ const e2ePwd = E2eService.getPassword();
173
+ const setupCommand = commandToken
174
+ ? `openclaw plugins install @botschat/botschat && \\
175
+ openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
176
+ openclaw config set channels.botschat.pairingToken ${commandToken} && \\${e2ePwd ? `\nopenclaw config set channels.botschat.e2ePassword "${e2ePwd}" && \\` : ""}
177
+ openclaw config set channels.botschat.enabled true && \\
178
+ openclaw gateway restart`
179
+ : null;
180
+
181
+ const isConnected = state.openclawConnected;
182
+
183
+ return (
184
+ <div className="space-y-5">
185
+ {/* ---- Connection Status ---- */}
186
+ <div>
187
+ <label
188
+ className="block text-caption font-bold mb-1.5"
189
+ style={{ color: "var(--text-secondary)" }}
190
+ >
191
+ OpenClaw Status
192
+ </label>
193
+ <div
194
+ className="flex items-center gap-3 rounded-md px-4 py-3"
195
+ style={{
196
+ background: isConnected ? "rgba(43, 172, 118, 0.1)" : "rgba(232, 162, 48, 0.1)",
197
+ border: `1px solid ${isConnected ? "rgba(43, 172, 118, 0.3)" : "rgba(232, 162, 48, 0.3)"}`,
198
+ }}
199
+ >
200
+ <span
201
+ className="w-2.5 h-2.5 rounded-full shrink-0"
202
+ style={{ background: isConnected ? "var(--accent-green)" : "var(--accent-yellow)" }}
203
+ />
204
+ <span
205
+ className="text-caption font-medium"
206
+ style={{ color: isConnected ? "var(--accent-green)" : "var(--accent-yellow)" }}
207
+ >
208
+ {isConnected ? "Connected to OpenClaw" : "Not connected"}
209
+ </span>
210
+ </div>
211
+ </div>
212
+
213
+ {/* ---- Setup Command ---- */}
214
+ <div>
215
+ <label
216
+ className="block text-caption font-bold mb-1.5"
217
+ style={{ color: "var(--text-secondary)" }}
218
+ >
219
+ Setup Command
220
+ </label>
221
+ <p className="text-tiny mb-2" style={{ color: "var(--text-muted)" }}>
222
+ Run this on your OpenClaw machine to install and connect the plugin.
223
+ </p>
224
+
225
+ {/* Loopback URL warning */}
226
+ {cloudUrlLoopback && (
227
+ <div
228
+ className="flex items-start gap-2 rounded-md px-3 py-2 mb-2 text-tiny"
229
+ style={{
230
+ background: "rgba(232, 162, 48, 0.1)",
231
+ border: "1px solid rgba(232, 162, 48, 0.25)",
232
+ color: "var(--accent-yellow)",
233
+ }}
234
+ >
235
+ <svg className="w-3.5 h-3.5 mt-0.5 shrink-0" viewBox="0 0 20 20" fill="currentColor">
236
+ <path
237
+ fillRule="evenodd"
238
+ d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.168 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 6a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 6zm0 9a1 1 0 100-2 1 1 0 000 2z"
239
+ clipRule="evenodd"
240
+ />
241
+ </svg>
242
+ <span>
243
+ {cloudUrlHint || "localhost URL may not be reachable from other machines."}{" "}
244
+ <button
245
+ onClick={() => setEditingUrl(true)}
246
+ className="underline font-medium hover:brightness-110"
247
+ style={{ color: "var(--text-link)" }}
248
+ >
249
+ Change URL
250
+ </button>
251
+ </span>
252
+ </div>
253
+ )}
254
+
255
+ {/* Editable cloud URL inline */}
256
+ {editingUrl && (
257
+ <div className="flex items-center gap-2 mb-2">
258
+ <label
259
+ className="text-tiny font-bold shrink-0"
260
+ style={{ color: "var(--text-secondary)" }}
261
+ >
262
+ Cloud URL:
263
+ </label>
264
+ <input
265
+ type="text"
266
+ value={cloudUrl}
267
+ onChange={(e) => {
268
+ setCloudUrl(e.target.value.replace(/\/+$/, ""));
269
+ setCloudUrlLoopback(false);
270
+ }}
271
+ className="flex-1 px-2.5 py-1 rounded-sm text-tiny font-mono"
272
+ style={{
273
+ background: "var(--code-bg)",
274
+ border: "1px solid var(--border)",
275
+ color: "var(--text-primary)",
276
+ outline: "none",
277
+ }}
278
+ placeholder="http://192.168.x.x:8787"
279
+ autoFocus
280
+ />
281
+ <button
282
+ onClick={() => setEditingUrl(false)}
283
+ className="px-2.5 py-1 text-tiny font-medium rounded-sm"
284
+ style={{ background: "var(--bg-active)", color: "#fff" }}
285
+ >
286
+ Done
287
+ </button>
288
+ </div>
289
+ )}
290
+
291
+ {loadingTokens ? (
292
+ <div
293
+ className="rounded-md px-3 py-2.5 animate-pulse"
294
+ style={{ background: "var(--code-bg)", height: "64px" }}
295
+ />
296
+ ) : setupCommand ? (
297
+ <CodeBlock code={setupCommand} />
298
+ ) : (
299
+ <div
300
+ className="rounded-md px-4 py-3 text-caption"
301
+ style={{
302
+ background: "var(--code-bg)",
303
+ border: "1px solid var(--border)",
304
+ color: "var(--text-muted)",
305
+ }}
306
+ >
307
+ {tokens.length > 0
308
+ ? "Create a new pairing token below to generate the setup command. (Token values are only shown once at creation time.)"
309
+ : "No pairing tokens available. Create one below to generate the setup command."}
310
+ </div>
311
+ )}
312
+
313
+ {/* Cloud URL (non-editable display, with Edit button) */}
314
+ {!editingUrl && (
315
+ <div className="flex items-center gap-2 mt-2">
316
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
317
+ Cloud URL:
318
+ </span>
319
+ <code className="text-tiny font-mono" style={{ color: "var(--text-secondary)" }}>
320
+ {cloudUrl}
321
+ </code>
322
+ <button
323
+ onClick={() => setEditingUrl(true)}
324
+ className="text-tiny hover:underline"
325
+ style={{ color: "var(--text-link)" }}
326
+ >
327
+ Edit
328
+ </button>
329
+ </div>
330
+ )}
331
+ </div>
332
+
333
+ {/* ---- Pairing Tokens ---- */}
334
+ <div>
335
+ <div className="flex items-center justify-between mb-1.5">
336
+ <label
337
+ className="text-caption font-bold"
338
+ style={{ color: "var(--text-secondary)" }}
339
+ >
340
+ Pairing Tokens
341
+ </label>
342
+ <button
343
+ onClick={() => setShowCreateToken(!showCreateToken)}
344
+ className="text-tiny font-medium hover:underline"
345
+ style={{ color: "var(--text-link)" }}
346
+ >
347
+ {showCreateToken ? "Cancel" : "+ New Token"}
348
+ </button>
349
+ </div>
350
+
351
+ {/* Create token form */}
352
+ {showCreateToken && (
353
+ <div
354
+ className="flex items-center gap-2 rounded-md px-3 py-2.5 mb-2"
355
+ style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
356
+ >
357
+ <input
358
+ type="text"
359
+ value={newTokenLabel}
360
+ onChange={(e) => setNewTokenLabel(e.target.value)}
361
+ placeholder="Token label (optional)"
362
+ className="flex-1 px-2 py-1 rounded-sm text-tiny"
363
+ style={{
364
+ background: "var(--bg-surface)",
365
+ border: "1px solid var(--border)",
366
+ color: "var(--text-primary)",
367
+ outline: "none",
368
+ }}
369
+ onKeyDown={(e) => {
370
+ if (e.key === "Enter") handleCreateToken();
371
+ }}
372
+ autoFocus
373
+ />
374
+ <button
375
+ onClick={handleCreateToken}
376
+ disabled={creatingToken}
377
+ className="px-3 py-1 text-tiny font-medium rounded-sm text-white"
378
+ style={{
379
+ background: creatingToken ? "var(--text-muted)" : "var(--bg-active)",
380
+ }}
381
+ >
382
+ {creatingToken ? "Creating..." : "Create"}
383
+ </button>
384
+ </div>
385
+ )}
386
+
387
+ {/* Freshly created token (highlight) */}
388
+ {freshToken && (
389
+ <div
390
+ className="rounded-md px-3 py-2.5 mb-2"
391
+ style={{
392
+ background: "rgba(43, 172, 118, 0.08)",
393
+ border: "1px solid rgba(43, 172, 118, 0.3)",
394
+ }}
395
+ >
396
+ <div className="flex items-center justify-between mb-1">
397
+ <span
398
+ className="text-tiny font-bold"
399
+ style={{ color: "var(--accent-green)" }}
400
+ >
401
+ New token created — copy it now (only shown once)
402
+ </span>
403
+ <button
404
+ onClick={() => setFreshToken(null)}
405
+ className="text-tiny"
406
+ style={{ color: "var(--text-muted)" }}
407
+ >
408
+ Dismiss
409
+ </button>
410
+ </div>
411
+ <div className="flex items-center gap-2">
412
+ <code
413
+ className="flex-1 text-tiny font-mono break-all"
414
+ style={{ color: "var(--text-primary)" }}
415
+ >
416
+ {freshToken.token}
417
+ </code>
418
+ <CopyButton text={freshToken.token} />
419
+ </div>
420
+ </div>
421
+ )}
422
+
423
+ {/* Token list */}
424
+ {loadingTokens ? (
425
+ <div
426
+ className="rounded-md px-3 py-4 animate-pulse"
427
+ style={{ background: "var(--code-bg)" }}
428
+ />
429
+ ) : tokens.length === 0 ? (
430
+ <p className="text-tiny py-2" style={{ color: "var(--text-muted)" }}>
431
+ No active pairing tokens. Create one to connect your OpenClaw agent.
432
+ </p>
433
+ ) : (
434
+ <div className="space-y-1">
435
+ {tokens.map((t) => (
436
+ <div
437
+ key={t.id}
438
+ className="flex items-center gap-2 rounded-md px-3 py-2"
439
+ style={{ background: "var(--code-bg)", border: "1px solid var(--border)" }}
440
+ >
441
+ <code
442
+ className="text-tiny font-mono shrink-0"
443
+ style={{ color: "var(--text-primary)" }}
444
+ >
445
+ {t.tokenPreview}
446
+ </code>
447
+ {t.label && (
448
+ <span
449
+ className="text-tiny px-1.5 py-0.5 rounded"
450
+ style={{
451
+ background: "var(--bg-hover)",
452
+ color: "var(--text-secondary)",
453
+ }}
454
+ >
455
+ {t.label}
456
+ </span>
457
+ )}
458
+ <span className="flex-1" />
459
+ <span className="text-tiny" style={{ color: "var(--text-muted)" }}>
460
+ {t.lastConnectedAt ? timeAgo(t.lastConnectedAt) : "Never connected"}
461
+ </span>
462
+ <button
463
+ onClick={() => handleRevokeToken(t.id)}
464
+ className="text-tiny font-medium hover:underline shrink-0"
465
+ style={{ color: "var(--accent-red)" }}
466
+ title="Revoke this token"
467
+ >
468
+ Revoke
469
+ </button>
470
+ </div>
471
+ ))}
472
+ </div>
473
+ )}
474
+ </div>
475
+ </div>
476
+ );
477
+ }