@nextclaw/ui 0.9.5 → 0.9.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 (56) hide show
  1. package/CHANGELOG.md +13 -1
  2. package/dist/assets/ChannelsList-BEbqjBdM.js +1 -0
  3. package/dist/assets/{ChatPage-DM1ewbWf.js → ChatPage-BaUQCtqU.js} +2 -2
  4. package/dist/assets/{DocBrowser-BLv77lJ0.js → DocBrowser-CtDiU1-0.js} +1 -1
  5. package/dist/assets/{LogoBadge-D7j1al-w.js → LogoBadge-Cy-EHxZ6.js} +1 -1
  6. package/dist/assets/MarketplacePage-D_r1Xy_C.js +49 -0
  7. package/dist/assets/{McpMarketplacePage-DpMjaD3m.js → McpMarketplacePage-Bftp9zkB.js} +2 -2
  8. package/dist/assets/ModelConfig-CdFIVFue.js +1 -0
  9. package/dist/assets/ProvidersList-Bx3w67f2.js +1 -0
  10. package/dist/assets/RemoteAccessPage-3JbDaO3y.js +1 -0
  11. package/dist/assets/{RuntimeConfig-BbX4yFKy.js → RuntimeConfig-V2C7bIJt.js} +1 -1
  12. package/dist/assets/{SearchConfig-BmmmeyJd.js → SearchConfig-Wq9VNK2x.js} +1 -1
  13. package/dist/assets/{SecretsConfig-CWG8J01H.js → SecretsConfig-DuYOs4z3.js} +2 -2
  14. package/dist/assets/SessionsConfig-fuPlbwC7.js +2 -0
  15. package/dist/assets/{chat-message-CGXiVhyN.js → chat-message-D729qtQ5.js} +1 -1
  16. package/dist/assets/index-D3eWL7gT.js +8 -0
  17. package/dist/assets/index-DfEAJJsA.css +1 -0
  18. package/dist/assets/{label-CCSffS1D.js → label-DCtPX8p3.js} +1 -1
  19. package/dist/assets/{page-layout-ud8wZ8gX.js → page-layout-Dr5ymRVA.js} +1 -1
  20. package/dist/assets/popover-YWmgsAlv.js +1 -0
  21. package/dist/assets/{security-config-DJJUCMov.js → security-config-bwP5X0a-.js} +1 -1
  22. package/dist/assets/skeleton-C4iXwmBW.js +1 -0
  23. package/dist/assets/{status-dot-Fz9-eKsl.js → status-dot-SDG0WNvL.js} +1 -1
  24. package/dist/assets/{switch-B-_SrMSL.js → switch-lC_seKUL.js} +1 -1
  25. package/dist/assets/{tabs-custom-6Tm1ZHfS.js → tabs-custom-LAWD0u69.js} +1 -1
  26. package/dist/assets/useConfirmDialog-D0akncY4.js +1 -0
  27. package/dist/assets/vendor-CmQZsDAE.js +436 -0
  28. package/dist/index.html +3 -3
  29. package/package.json +4 -4
  30. package/src/App.tsx +36 -39
  31. package/src/account/components/account-panel.tsx +93 -0
  32. package/src/account/managers/account.manager.ts +179 -0
  33. package/src/account/stores/account.store.ts +68 -0
  34. package/src/app-query-client.ts +10 -0
  35. package/src/components/layout/Sidebar.tsx +26 -0
  36. package/src/components/remote/RemoteAccessPage.tsx +162 -442
  37. package/src/hooks/useRemoteAccess.ts +7 -6
  38. package/src/lib/i18n.remote.ts +108 -4
  39. package/src/presenter/app-presenter-context.tsx +20 -0
  40. package/src/presenter/app.presenter.ts +12 -0
  41. package/src/remote/managers/remote-access.manager.ts +196 -0
  42. package/src/remote/remote-access.query.ts +78 -0
  43. package/src/remote/stores/remote-access.store.ts +44 -0
  44. package/dist/assets/ChannelsList-Byfj2R01.js +0 -1
  45. package/dist/assets/MarketplacePage-DuskLKYh.js +0 -49
  46. package/dist/assets/ModelConfig-ubaecweS.js +0 -1
  47. package/dist/assets/ProvidersList-w8MJH2LI.js +0 -1
  48. package/dist/assets/RemoteAccessPage-D79_5Kbn.js +0 -1
  49. package/dist/assets/SessionsConfig-D-vg_Lgv.js +0 -2
  50. package/dist/assets/index-COrhpAdh.css +0 -1
  51. package/dist/assets/index-CeRbsQ90.js +0 -8
  52. package/dist/assets/index-Ct7FQpxN.js +0 -1
  53. package/dist/assets/popover-Bfoe6YBX.js +0 -1
  54. package/dist/assets/skeleton-IOOTmHzP.js +0 -1
  55. package/dist/assets/useConfirmDialog-BeOW2bOI.js +0 -5
  56. package/dist/assets/vendor-CwsIoNvJ.js +0 -442
package/dist/index.html CHANGED
@@ -6,9 +6,9 @@
6
6
  <link rel="icon" type="image/svg+xml" href="/logo.svg" />
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
8
  <title>NextClaw - 系统配置</title>
9
- <script type="module" crossorigin src="/assets/index-CeRbsQ90.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-CwsIoNvJ.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-COrhpAdh.css">
9
+ <script type="module" crossorigin src="/assets/index-D3eWL7gT.js"></script>
10
+ <link rel="modulepreload" crossorigin href="/assets/vendor-CmQZsDAE.js">
11
+ <link rel="stylesheet" crossorigin href="/assets/index-DfEAJJsA.css">
12
12
  </head>
13
13
 
14
14
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.9.5",
3
+ "version": "0.9.7",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,11 +27,11 @@
27
27
  "tailwind-merge": "^2.5.4",
28
28
  "zod": "^3.23.8",
29
29
  "zustand": "^5.0.2",
30
- "@nextclaw/ncp": "0.3.1",
31
30
  "@nextclaw/agent-chat": "0.1.1",
32
- "@nextclaw/ncp-http-agent-client": "0.3.1",
31
+ "@nextclaw/ncp": "0.3.1",
33
32
  "@nextclaw/ncp-react": "0.3.2",
34
- "@nextclaw/agent-chat-ui": "0.2.1"
33
+ "@nextclaw/agent-chat-ui": "0.2.1",
34
+ "@nextclaw/ncp-http-agent-client": "0.3.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@testing-library/react": "^16.3.0",
package/src/App.tsx CHANGED
@@ -1,21 +1,15 @@
1
1
  import { lazy, Suspense } from 'react';
2
- import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { QueryClientProvider } from '@tanstack/react-query';
3
+ import { AccountPanel } from '@/account/components/account-panel';
4
+ import { appQueryClient } from '@/app-query-client';
3
5
  import { LoginPage } from '@/components/auth/login-page';
4
6
  import { AppLayout } from '@/components/layout/AppLayout';
5
7
  import { useAuthStatus } from '@/hooks/use-auth';
6
8
  import { useWebSocket } from '@/hooks/useWebSocket';
9
+ import { AppPresenterProvider } from '@/presenter/app-presenter-context';
7
10
  import { Toaster } from 'sonner';
8
11
  import { Routes, Route, Navigate } from 'react-router-dom';
9
12
 
10
- const queryClient = new QueryClient({
11
- defaultOptions: {
12
- queries: {
13
- retry: 1,
14
- refetchOnWindowFocus: true
15
- }
16
- }
17
- });
18
-
19
13
  const ModelConfigPage = lazy(async () => ({ default: (await import('@/components/config/ModelConfig')).ModelConfig }));
20
14
  const ChatPage = lazy(async () => ({ default: (await import('@/components/chat/ChatPage')).ChatPage }));
21
15
  const SearchConfigPage = lazy(async () => ({ default: (await import('@/components/config/SearchConfig')).SearchConfig }));
@@ -38,36 +32,39 @@ function LazyRoute({ children }: { children: JSX.Element }) {
38
32
  }
39
33
 
40
34
  function ProtectedApp() {
41
- useWebSocket(queryClient); // Initialize WebSocket connection
35
+ useWebSocket(appQueryClient); // Initialize WebSocket connection
42
36
 
43
37
  return (
44
- <AppLayout>
45
- <div className="w-full h-full">
46
- <Routes>
47
- <Route path="/chat/skills" element={<Navigate to="/skills" replace />} />
48
- <Route path="/chat/cron" element={<Navigate to="/cron" replace />} />
49
- <Route path="/chat/:sessionId?" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
50
- <Route path="/skills" element={<LazyRoute><ChatPage view="skills" /></LazyRoute>} />
51
- <Route path="/cron" element={<LazyRoute><ChatPage view="cron" /></LazyRoute>} />
52
- <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
53
- <Route path="/search" element={<LazyRoute><SearchConfigPage /></LazyRoute>} />
54
- <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
55
- <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
56
- <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
57
- <Route path="/remote" element={<LazyRoute><RemoteAccessPage /></LazyRoute>} />
58
- <Route path="/security" element={<LazyRoute><SecurityConfigPage /></LazyRoute>} />
59
- <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
60
- <Route path="/secrets" element={<LazyRoute><SecretsConfigPage /></LazyRoute>} />
61
- <Route path="/settings" element={<Navigate to="/model" replace />} />
62
- <Route path="/marketplace/skills" element={<Navigate to="/skills" replace />} />
63
- <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
64
- <Route path="/marketplace/mcp" element={<LazyRoute><McpMarketplacePage /></LazyRoute>} />
65
- <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
66
- <Route path="/" element={<Navigate to="/chat" replace />} />
67
- <Route path="*" element={<Navigate to="/chat" replace />} />
68
- </Routes>
69
- </div>
70
- </AppLayout>
38
+ <AppPresenterProvider>
39
+ <AppLayout>
40
+ <div className="w-full h-full">
41
+ <Routes>
42
+ <Route path="/chat/skills" element={<Navigate to="/skills" replace />} />
43
+ <Route path="/chat/cron" element={<Navigate to="/cron" replace />} />
44
+ <Route path="/chat/:sessionId?" element={<LazyRoute><ChatPage view="chat" /></LazyRoute>} />
45
+ <Route path="/skills" element={<LazyRoute><ChatPage view="skills" /></LazyRoute>} />
46
+ <Route path="/cron" element={<LazyRoute><ChatPage view="cron" /></LazyRoute>} />
47
+ <Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
48
+ <Route path="/search" element={<LazyRoute><SearchConfigPage /></LazyRoute>} />
49
+ <Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
50
+ <Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
51
+ <Route path="/runtime" element={<LazyRoute><RuntimeConfigPage /></LazyRoute>} />
52
+ <Route path="/remote" element={<LazyRoute><RemoteAccessPage /></LazyRoute>} />
53
+ <Route path="/security" element={<LazyRoute><SecurityConfigPage /></LazyRoute>} />
54
+ <Route path="/sessions" element={<LazyRoute><SessionsConfigPage /></LazyRoute>} />
55
+ <Route path="/secrets" element={<LazyRoute><SecretsConfigPage /></LazyRoute>} />
56
+ <Route path="/settings" element={<Navigate to="/model" replace />} />
57
+ <Route path="/marketplace/skills" element={<Navigate to="/skills" replace />} />
58
+ <Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
59
+ <Route path="/marketplace/mcp" element={<LazyRoute><McpMarketplacePage /></LazyRoute>} />
60
+ <Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
61
+ <Route path="/" element={<Navigate to="/chat" replace />} />
62
+ <Route path="*" element={<Navigate to="/chat" replace />} />
63
+ </Routes>
64
+ </div>
65
+ </AppLayout>
66
+ <AccountPanel />
67
+ </AppPresenterProvider>
71
68
  );
72
69
  }
73
70
 
@@ -87,7 +84,7 @@ function AuthGate() {
87
84
 
88
85
  export default function AppContent() {
89
86
  return (
90
- <QueryClientProvider client={queryClient}>
87
+ <QueryClientProvider client={appQueryClient}>
91
88
  <AuthGate />
92
89
  <Toaster position="top-right" richColors />
93
90
  </QueryClientProvider>
@@ -0,0 +1,93 @@
1
+ import { Button } from '@/components/ui/button';
2
+ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
3
+ import { useRemoteStatus } from '@/hooks/useRemoteAccess';
4
+ import { formatDateTime, t } from '@/lib/i18n';
5
+ import { useAccountStore } from '@/account/stores/account.store';
6
+ import { useAppPresenter } from '@/presenter/app-presenter-context';
7
+ import { KeyRound, LogOut, SquareArrowOutUpRight } from 'lucide-react';
8
+ import { useEffect } from 'react';
9
+
10
+ function AccountValueRow(props: { label: string; value?: string | null }) {
11
+ return (
12
+ <div className="flex items-start justify-between gap-4 py-2 text-sm">
13
+ <span className="text-gray-500">{props.label}</span>
14
+ <span className="text-right text-gray-900">{props.value?.trim() || '-'}</span>
15
+ </div>
16
+ );
17
+ }
18
+
19
+ export function AccountPanel() {
20
+ const presenter = useAppPresenter();
21
+ const remoteStatus = useRemoteStatus();
22
+ const panelOpen = useAccountStore((state) => state.panelOpen);
23
+ const authSessionId = useAccountStore((state) => state.authSessionId);
24
+ const authVerificationUri = useAccountStore((state) => state.authVerificationUri);
25
+ const authExpiresAt = useAccountStore((state) => state.authExpiresAt);
26
+ const authStatusMessage = useAccountStore((state) => state.authStatusMessage);
27
+ const status = remoteStatus.data;
28
+
29
+ useEffect(() => {
30
+ presenter.accountManager.syncRemoteStatus(status);
31
+ }, [presenter, status]);
32
+
33
+ return (
34
+ <Dialog open={panelOpen} onOpenChange={(open) => (open ? presenter.accountManager.openAccountPanel() : presenter.accountManager.closeAccountPanel())}>
35
+ <DialogContent className="max-w-xl">
36
+ <DialogHeader>
37
+ <DialogTitle className="flex items-center gap-2">
38
+ <KeyRound className="h-5 w-5 text-primary" />
39
+ {t('accountPanelTitle')}
40
+ </DialogTitle>
41
+ <DialogDescription>{t('accountPanelDescription')}</DialogDescription>
42
+ </DialogHeader>
43
+
44
+ {status?.account.loggedIn ? (
45
+ <div className="space-y-4">
46
+ <div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3">
47
+ <p className="text-sm font-medium text-emerald-800">{t('accountPanelSignedInTitle')}</p>
48
+ <p className="mt-1 text-sm text-emerald-700">{t('accountPanelSignedInDescription')}</p>
49
+ </div>
50
+ <div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
51
+ <AccountValueRow label={t('remoteAccountEmail')} value={status.account.email} />
52
+ <AccountValueRow label={t('remoteAccountRole')} value={status.account.role} />
53
+ </div>
54
+ <div className="flex flex-wrap gap-3">
55
+ <Button onClick={() => void presenter.accountManager.openNextClawWeb()}>
56
+ <SquareArrowOutUpRight className="mr-2 h-4 w-4" />
57
+ {t('remoteOpenDeviceList')}
58
+ </Button>
59
+ <Button variant="outline" onClick={() => void presenter.accountManager.logout()}>
60
+ <LogOut className="mr-2 h-4 w-4" />
61
+ {t('remoteLogout')}
62
+ </Button>
63
+ </div>
64
+ </div>
65
+ ) : (
66
+ <div className="space-y-4">
67
+ <div className="rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3">
68
+ <p className="text-sm font-medium text-gray-900">{t('accountPanelSignedOutTitle')}</p>
69
+ <p className="mt-1 text-sm text-gray-600">{t('accountPanelSignedOutDescription')}</p>
70
+ {authSessionId ? (
71
+ <div className="mt-3 border-t border-white/80 pt-3">
72
+ <AccountValueRow label={t('remoteBrowserAuthSession')} value={authSessionId} />
73
+ <AccountValueRow label={t('remoteBrowserAuthExpiresAt')} value={authExpiresAt ? formatDateTime(authExpiresAt) : '-'} />
74
+ </div>
75
+ ) : null}
76
+ </div>
77
+ {authStatusMessage ? <p className="text-sm text-gray-600">{authStatusMessage}</p> : null}
78
+ <div className="flex flex-wrap gap-3">
79
+ <Button onClick={() => void presenter.accountManager.startBrowserSignIn()}>
80
+ {authSessionId ? t('remoteBrowserAuthActionRetry') : t('remoteBrowserAuthAction')}
81
+ </Button>
82
+ {authVerificationUri ? (
83
+ <Button variant="outline" onClick={() => presenter.accountManager.resumeBrowserSignIn()}>
84
+ {t('remoteBrowserAuthResume')}
85
+ </Button>
86
+ ) : null}
87
+ </div>
88
+ </div>
89
+ )}
90
+ </DialogContent>
91
+ </Dialog>
92
+ );
93
+ }
@@ -0,0 +1,179 @@
1
+ import { logoutRemote, pollRemoteBrowserAuth, startRemoteBrowserAuth } from '@/api/remote';
2
+ import type { RemoteAccessView } from '@/api/remote.types';
3
+ import type { AccountPendingAction } from '@/account/stores/account.store';
4
+ import { useAccountStore } from '@/account/stores/account.store';
5
+ import {
6
+ ensureRemoteStatus,
7
+ refreshRemoteStatus,
8
+ resolveRemotePlatformApiBase,
9
+ resolveRemoteWebBase
10
+ } from '@/remote/remote-access.query';
11
+ import { formatDateTime, t } from '@/lib/i18n';
12
+ import { toast } from 'sonner';
13
+
14
+ type SignedInContinuation = (action: AccountPendingAction, status: RemoteAccessView) => Promise<void>;
15
+
16
+ export class AccountManager {
17
+ private authPollTimerId: number | null = null;
18
+
19
+ private afterSignedIn: SignedInContinuation | null = null;
20
+
21
+ bindSignedInContinuation = (handler: SignedInContinuation) => {
22
+ this.afterSignedIn = handler;
23
+ };
24
+
25
+ openAccountPanel = () => {
26
+ useAccountStore.getState().openPanel();
27
+ };
28
+
29
+ closeAccountPanel = () => {
30
+ useAccountStore.getState().closePanel();
31
+ };
32
+
33
+ syncRemoteStatus = (status: RemoteAccessView | undefined) => {
34
+ if (!status?.account.loggedIn) {
35
+ return;
36
+ }
37
+ this.clearPollTimer();
38
+ useAccountStore.getState().clearBrowserAuth();
39
+ };
40
+
41
+ ensureSignedIn = async (params?: { pendingAction?: AccountPendingAction; apiBase?: string }) => {
42
+ const status = await ensureRemoteStatus();
43
+ if (status.account.loggedIn) {
44
+ return true;
45
+ }
46
+ if (params?.pendingAction) {
47
+ useAccountStore.getState().setPendingAction(params.pendingAction);
48
+ }
49
+ this.openAccountPanel();
50
+ await this.startBrowserSignIn({ apiBase: params?.apiBase, status });
51
+ return false;
52
+ };
53
+
54
+ startBrowserSignIn = async (params?: { apiBase?: string; status?: RemoteAccessView }) => {
55
+ try {
56
+ const status = params?.status ?? (await ensureRemoteStatus());
57
+ const result = await startRemoteBrowserAuth({
58
+ apiBase: resolveRemotePlatformApiBase(status, params?.apiBase)
59
+ });
60
+ useAccountStore.getState().beginBrowserAuth({
61
+ sessionId: result.sessionId,
62
+ verificationUri: result.verificationUri,
63
+ expiresAt: result.expiresAt,
64
+ intervalMs: result.intervalMs,
65
+ statusMessage: t('remoteBrowserAuthWaiting')
66
+ });
67
+ const opened = window.open(result.verificationUri, '_blank', 'noopener,noreferrer');
68
+ if (!opened) {
69
+ useAccountStore.getState().setAuthStatusMessage(t('remoteBrowserAuthPopupBlocked'));
70
+ }
71
+ this.scheduleBrowserAuthPoll();
72
+ } catch (error) {
73
+ const message = error instanceof Error ? error.message : t('remoteBrowserAuthStartFailed');
74
+ toast.error(`${t('remoteBrowserAuthStartFailed')}: ${message}`);
75
+ }
76
+ };
77
+
78
+ resumeBrowserSignIn = () => {
79
+ const verificationUri = useAccountStore.getState().authVerificationUri;
80
+ if (!verificationUri) {
81
+ return;
82
+ }
83
+ window.open(verificationUri, '_blank', 'noopener,noreferrer');
84
+ };
85
+
86
+ logout = async () => {
87
+ try {
88
+ await logoutRemote();
89
+ useAccountStore.getState().clearPendingAction();
90
+ useAccountStore.getState().clearBrowserAuth();
91
+ await refreshRemoteStatus();
92
+ toast.success(t('remoteLogoutSuccess'));
93
+ } catch (error) {
94
+ const message = error instanceof Error ? error.message : t('remoteLogoutFailed');
95
+ toast.error(`${t('remoteLogoutFailed')}: ${message}`);
96
+ }
97
+ };
98
+
99
+ openNextClawWeb = async () => {
100
+ const status = await ensureRemoteStatus();
101
+ const webBase = resolveRemoteWebBase(status);
102
+ if (!webBase) {
103
+ toast.error(t('remoteOpenWebUnavailable'));
104
+ return;
105
+ }
106
+ window.open(webBase, '_blank', 'noopener,noreferrer');
107
+ };
108
+
109
+ private scheduleBrowserAuthPoll = () => {
110
+ this.clearPollTimer();
111
+ const { authSessionId, authPollIntervalMs } = useAccountStore.getState();
112
+ if (!authSessionId) {
113
+ return;
114
+ }
115
+ this.authPollTimerId = window.setTimeout(async () => {
116
+ await this.pollBrowserSignIn();
117
+ }, authPollIntervalMs);
118
+ };
119
+
120
+ private pollBrowserSignIn = async () => {
121
+ const store = useAccountStore.getState();
122
+ if (!store.authSessionId) {
123
+ return;
124
+ }
125
+
126
+ try {
127
+ const status = await ensureRemoteStatus();
128
+ const result = await pollRemoteBrowserAuth({
129
+ sessionId: store.authSessionId,
130
+ apiBase: resolveRemotePlatformApiBase(status)
131
+ });
132
+ if (result.status === 'pending') {
133
+ useAccountStore.getState().updateBrowserAuth({
134
+ statusMessage: t('remoteBrowserAuthWaiting'),
135
+ intervalMs: result.nextPollMs ?? 1500
136
+ });
137
+ this.scheduleBrowserAuthPoll();
138
+ return;
139
+ }
140
+ if (result.status === 'expired') {
141
+ this.clearPollTimer();
142
+ useAccountStore.getState().clearBrowserAuth();
143
+ toast.error(result.message || t('remoteBrowserAuthExpired'));
144
+ return;
145
+ }
146
+
147
+ useAccountStore.getState().setAuthStatusMessage(t('remoteBrowserAuthCompleted'));
148
+ const nextStatus = await refreshRemoteStatus();
149
+ const { pendingAction } = useAccountStore.getState();
150
+ this.clearPollTimer();
151
+ useAccountStore.getState().clearBrowserAuth();
152
+ toast.success(t('remoteLoginSuccess'));
153
+ if (pendingAction && this.afterSignedIn) {
154
+ await this.afterSignedIn(pendingAction, nextStatus);
155
+ }
156
+ useAccountStore.getState().clearPendingAction();
157
+ } catch (error) {
158
+ this.clearPollTimer();
159
+ useAccountStore.getState().clearBrowserAuth();
160
+ const message = error instanceof Error ? error.message : t('remoteBrowserAuthPollFailed');
161
+ toast.error(`${t('remoteBrowserAuthPollFailed')}: ${message}`);
162
+ }
163
+ };
164
+
165
+ private clearPollTimer = () => {
166
+ if (this.authPollTimerId !== null) {
167
+ window.clearTimeout(this.authPollTimerId);
168
+ this.authPollTimerId = null;
169
+ }
170
+ };
171
+
172
+ getBrowserAuthSummary = () => {
173
+ const store = useAccountStore.getState();
174
+ return {
175
+ sessionId: store.authSessionId,
176
+ expiresAt: store.authExpiresAt ? formatDateTime(store.authExpiresAt) : '-'
177
+ };
178
+ };
179
+ }
@@ -0,0 +1,68 @@
1
+ import { create } from 'zustand';
2
+
3
+ export type AccountPendingAction =
4
+ | {
5
+ type: 'enable-remote';
6
+ }
7
+ | null;
8
+
9
+ type AccountStoreState = {
10
+ panelOpen: boolean;
11
+ authSessionId: string | null;
12
+ authVerificationUri: string | null;
13
+ authExpiresAt: string | null;
14
+ authStatusMessage: string;
15
+ authPollIntervalMs: number;
16
+ pendingAction: AccountPendingAction;
17
+ openPanel: () => void;
18
+ closePanel: () => void;
19
+ setPendingAction: (next: AccountPendingAction) => void;
20
+ clearPendingAction: () => void;
21
+ beginBrowserAuth: (payload: {
22
+ sessionId: string;
23
+ verificationUri: string;
24
+ expiresAt: string;
25
+ intervalMs: number;
26
+ statusMessage: string;
27
+ }) => void;
28
+ updateBrowserAuth: (patch: { statusMessage?: string; intervalMs?: number }) => void;
29
+ clearBrowserAuth: () => void;
30
+ setAuthStatusMessage: (message: string) => void;
31
+ };
32
+
33
+ export const useAccountStore = create<AccountStoreState>((set) => ({
34
+ panelOpen: false,
35
+ authSessionId: null,
36
+ authVerificationUri: null,
37
+ authExpiresAt: null,
38
+ authStatusMessage: '',
39
+ authPollIntervalMs: 1500,
40
+ pendingAction: null,
41
+ openPanel: () => set({ panelOpen: true }),
42
+ closePanel: () => set({ panelOpen: false }),
43
+ setPendingAction: (next) => set({ pendingAction: next }),
44
+ clearPendingAction: () => set({ pendingAction: null }),
45
+ beginBrowserAuth: ({ sessionId, verificationUri, expiresAt, intervalMs, statusMessage }) =>
46
+ set({
47
+ panelOpen: true,
48
+ authSessionId: sessionId,
49
+ authVerificationUri: verificationUri,
50
+ authExpiresAt: expiresAt,
51
+ authPollIntervalMs: intervalMs,
52
+ authStatusMessage: statusMessage
53
+ }),
54
+ updateBrowserAuth: ({ statusMessage, intervalMs }) =>
55
+ set((state) => ({
56
+ authStatusMessage: statusMessage ?? state.authStatusMessage,
57
+ authPollIntervalMs: intervalMs ?? state.authPollIntervalMs
58
+ })),
59
+ clearBrowserAuth: () =>
60
+ set({
61
+ authSessionId: null,
62
+ authVerificationUri: null,
63
+ authExpiresAt: null,
64
+ authStatusMessage: '',
65
+ authPollIntervalMs: 1500
66
+ }),
67
+ setAuthStatusMessage: (message) => set({ authStatusMessage: message })
68
+ }));
@@ -0,0 +1,10 @@
1
+ import { QueryClient } from '@tanstack/react-query';
2
+
3
+ export const appQueryClient = new QueryClient({
4
+ defaultOptions: {
5
+ queries: {
6
+ retry: 1,
7
+ refetchOnWindowFocus: true
8
+ }
9
+ }
10
+ });
@@ -8,6 +8,8 @@ import { BrandHeader } from '@/components/common/BrandHeader';
8
8
  import { useI18n } from '@/components/providers/I18nProvider';
9
9
  import { useTheme } from '@/components/providers/ThemeProvider';
10
10
  import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
11
+ import { useRemoteStatus } from '@/hooks/useRemoteAccess';
12
+ import { useAppPresenter } from '@/presenter/app-presenter-context';
11
13
 
12
14
  type SidebarMode = 'main' | 'settings';
13
15
 
@@ -16,11 +18,15 @@ type SidebarProps = {
16
18
  };
17
19
 
18
20
  export function Sidebar({ mode }: SidebarProps) {
21
+ const presenter = useAppPresenter();
19
22
  const docBrowser = useDocBrowser();
23
+ const remoteStatus = useRemoteStatus();
20
24
  const { language, setLanguage } = useI18n();
21
25
  const { theme, setTheme } = useTheme();
22
26
  const currentLanguageLabel = LANGUAGE_OPTIONS.find((option) => option.value === language)?.label ?? language;
23
27
  const currentThemeLabel = t(THEME_OPTIONS.find((option) => option.value === theme)?.labelKey ?? 'themeWarm');
28
+ const accountEmail = remoteStatus.data?.account.email?.trim();
29
+ const accountConnected = Boolean(remoteStatus.data?.account.loggedIn);
24
30
 
25
31
  const handleLanguageSwitch = (nextLanguage: I18nLanguage) => {
26
32
  if (language === nextLanguage) {
@@ -174,6 +180,26 @@ export function Sidebar({ mode }: SidebarProps) {
174
180
 
175
181
  {/* Help Button */}
176
182
  <div className="pt-3 border-t border-[#dde0ea] mt-3">
183
+ {mode === 'settings' ? (
184
+ <button
185
+ onClick={() => presenter.accountManager.openAccountPanel()}
186
+ className="mb-2 w-full rounded-xl px-3 py-2.5 text-left transition-all duration-base text-gray-600 hover:bg-[#e4e7ef] hover:text-gray-900"
187
+ >
188
+ <div className="flex items-start gap-3">
189
+ <KeyRound className={cn('mt-0.5 h-[17px] w-[17px]', accountConnected ? 'text-emerald-600' : 'text-gray-400')} />
190
+ <div className="min-w-0 flex-1">
191
+ <div className="flex items-center justify-between gap-3">
192
+ <p className="truncate text-[14px] font-medium text-gray-900">
193
+ {accountEmail || t('remoteAccountEntryManage')}
194
+ </p>
195
+ </div>
196
+ <p className="mt-1 truncate text-xs text-gray-500">
197
+ {accountConnected ? t('remoteAccountEntryConnected') : t('remoteAccountEntryDisconnected')}
198
+ </p>
199
+ </div>
200
+ </div>
201
+ </button>
202
+ ) : null}
177
203
  {mode === 'main' && (
178
204
  <div className="mb-2">
179
205
  <NavLink