OrcaCloudView.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307
  1. import { useEffect, useRef, useState } from 'react';
  2. import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
  3. import { useTranslation } from 'react-i18next';
  4. import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, Check } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { OrcaDeviceStartResponse, OrcaDevicePollStatus } from '../api/client';
  7. import { Card, CardContent } from './Card';
  8. import { Button } from './Button';
  9. import { useToast } from '../contexts/ToastContext';
  10. import { useAuth } from '../contexts/AuthContext';
  11. import { OrcaCloudProfilesView } from './OrcaCloudProfilesView';
  12. /**
  13. * Orca Cloud profile sync tab.
  14. *
  15. * Auth uses the RFC 8628 device-authorization grant: the backend requests a
  16. * device code from Orca and returns a short user_code plus a verification link.
  17. * The user opens the link, approves the code in their Orca Cloud settings, and
  18. * Bambuddy polls the backend (which polls Orca's token endpoint) until the
  19. * pairing completes. No redirect URL, no callback paste, no client secret —
  20. * see backend/app/services/orca_cloud.py for the deep dive.
  21. */
  22. export function OrcaCloudView() {
  23. const { t } = useTranslation();
  24. const queryClient = useQueryClient();
  25. const { showToast } = useToast();
  26. const { hasPermission } = useAuth();
  27. const canManage = hasPermission('orca_cloud:auth');
  28. // Pairing sub-state: null until the user clicks Connect, then the device
  29. // response (code + link) that we display while polling.
  30. const [pairing, setPairing] = useState<OrcaDeviceStartResponse | null>(null);
  31. const [pollIntervalMs, setPollIntervalMs] = useState(5000);
  32. const [connectError, setConnectError] = useState<string | null>(null);
  33. const { data: status, isLoading: statusLoading } = useQuery({
  34. queryKey: ['orcaCloudStatus'],
  35. queryFn: api.orcaCloudStatus,
  36. });
  37. const connected = !!status?.connected;
  38. const {
  39. data: profilesData,
  40. isLoading: profilesLoading,
  41. refetch: refetchProfiles,
  42. isRefetching: profilesRefetching,
  43. error: profilesError,
  44. dataUpdatedAt: profilesUpdatedAt,
  45. } = useQuery({
  46. queryKey: ['orcaCloudProfiles'],
  47. queryFn: api.orcaCloudListProfiles,
  48. enabled: connected,
  49. retry: false,
  50. staleTime: 1000 * 60 * 5,
  51. });
  52. // Configured Bambuddy printers — fed into the profile-view's printer
  53. // filter dropdown so the user can narrow profiles to a specific printer
  54. // model. Same usage as the Bambu Cloud tab.
  55. const { data: printers = [] } = useQuery({
  56. queryKey: ['printers'],
  57. queryFn: api.getPrinters,
  58. enabled: connected,
  59. });
  60. const [lastSyncTime, setLastSyncTime] = useState<Date | undefined>();
  61. useEffect(() => {
  62. if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt));
  63. }, [profilesUpdatedAt]);
  64. const finishPairing = () => {
  65. setPairing(null);
  66. setPollIntervalMs(5000);
  67. };
  68. const handleTerminal = (status: OrcaDevicePollStatus) => {
  69. if (status === 'access_denied') setConnectError(t('profiles.orcaCloud.errors.denied'));
  70. else if (status === 'expired_token') setConnectError(t('profiles.orcaCloud.errors.expired'));
  71. finishPairing();
  72. };
  73. const startMutation = useMutation({
  74. mutationFn: api.orcaCloudDeviceStart,
  75. onSuccess: (data) => {
  76. setConnectError(null);
  77. setPollIntervalMs(Math.max(1, data.interval) * 1000);
  78. setPairing(data);
  79. },
  80. onError: (err: Error) => {
  81. setConnectError(err.message || t('profiles.orcaCloud.errors.startFailed'));
  82. },
  83. });
  84. const logoutMutation = useMutation({
  85. mutationFn: api.orcaCloudLogout,
  86. onSuccess: () => {
  87. queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
  88. queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] });
  89. showToast(t('profiles.orcaCloud.toast.disconnected'));
  90. },
  91. });
  92. // Poll the backend while a pairing is in flight. react-query drives the
  93. // cadence; the effect below reacts to each poll result. refetchInterval
  94. // returns false once we stop (pairing cleared), which halts polling.
  95. const { data: pollData, error: pollError } = useQuery({
  96. // Scope the cache per pairing attempt so a fresh Connect never re-consumes
  97. // a previous attempt's cached 'complete'/terminal result.
  98. queryKey: ['orcaCloudDevicePoll', pairing?.user_code ?? 'none'],
  99. queryFn: api.orcaCloudDevicePoll,
  100. enabled: pairing !== null,
  101. gcTime: 0,
  102. retry: false,
  103. refetchOnWindowFocus: false,
  104. refetchInterval: pairing !== null ? pollIntervalMs : false,
  105. });
  106. // A ref so the poll-result effect can act exactly once per new result
  107. // without re-running when unrelated state (interval, etc.) changes.
  108. const lastHandledStatus = useRef<OrcaDevicePollStatus | null>(null);
  109. useEffect(() => {
  110. if (!pairing || !pollData) return;
  111. const s = pollData.status;
  112. if (s === 'slow_down') {
  113. // Back off as the RFC prescribes, then keep waiting.
  114. setPollIntervalMs((ms) => ms + 5000);
  115. return;
  116. }
  117. if (s === 'authorization_pending') return;
  118. if (lastHandledStatus.current === s) return;
  119. lastHandledStatus.current = s;
  120. if (s === 'complete') {
  121. finishPairing();
  122. queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
  123. queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
  124. showToast(t('profiles.orcaCloud.connectedShort'));
  125. } else {
  126. handleTerminal(s);
  127. }
  128. // eslint-disable-next-line react-hooks/exhaustive-deps
  129. }, [pollData, pairing]);
  130. // A poll HTTP error (e.g. the pending state vanished server-side) ends the
  131. // flow rather than spinning forever.
  132. useEffect(() => {
  133. if (pairing && pollError) {
  134. setConnectError(t('profiles.orcaCloud.errors.pollFailed'));
  135. finishPairing();
  136. }
  137. // eslint-disable-next-line react-hooks/exhaustive-deps
  138. }, [pollError, pairing]);
  139. // Reset the one-shot guard whenever a new pairing starts.
  140. useEffect(() => {
  141. if (pairing) lastHandledStatus.current = null;
  142. }, [pairing]);
  143. if (statusLoading) {
  144. return (
  145. <div className="flex items-center justify-center py-16">
  146. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  147. </div>
  148. );
  149. }
  150. return (
  151. <div>
  152. {connected && (
  153. <div className="flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  154. <div className="flex items-center gap-3">
  155. <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
  156. <span className="text-sm text-bambu-gray">
  157. {status?.email ? (
  158. <>
  159. {t('profiles.orcaCloud.connectedAs')} <span className="text-white">{status.email}</span>
  160. </>
  161. ) : (
  162. <span className="text-white">{t('profiles.orcaCloud.connectedShort')}</span>
  163. )}
  164. </span>
  165. </div>
  166. <Button
  167. variant="secondary"
  168. size="sm"
  169. onClick={() => logoutMutation.mutate()}
  170. disabled={logoutMutation.isPending || !canManage}
  171. title={!canManage ? t('profiles.orcaCloud.noLogoutPermission') : undefined}
  172. >
  173. <LogOut className="w-4 h-4" />
  174. {t('profiles.orcaCloud.logout')}
  175. </Button>
  176. </div>
  177. )}
  178. {!connected ? (
  179. <ConnectCard
  180. pairing={pairing}
  181. connectError={connectError}
  182. onConnect={() => startMutation.mutate()}
  183. onCancel={finishPairing}
  184. isStarting={startMutation.isPending}
  185. canManage={canManage}
  186. t={t}
  187. />
  188. ) : profilesLoading ? (
  189. <div className="flex items-center justify-center py-16">
  190. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  191. </div>
  192. ) : profilesError ? (
  193. <div className="text-center py-16">
  194. <p className="text-bambu-gray mb-4">{(profilesError as Error).message}</p>
  195. <Button onClick={() => refetchProfiles()}>{t('profiles.orcaCloud.retry')}</Button>
  196. </div>
  197. ) : profilesData ? (
  198. <OrcaCloudProfilesView
  199. settings={profilesData}
  200. lastSyncTime={lastSyncTime}
  201. onRefresh={() => refetchProfiles()}
  202. isRefreshing={profilesRefetching}
  203. printers={printers}
  204. t={t}
  205. />
  206. ) : null}
  207. </div>
  208. );
  209. }
  210. interface ConnectCardProps {
  211. pairing: OrcaDeviceStartResponse | null;
  212. connectError: string | null;
  213. onConnect: () => void;
  214. onCancel: () => void;
  215. isStarting: boolean;
  216. canManage: boolean;
  217. t: (key: string, opts?: Record<string, string>) => string;
  218. }
  219. function ConnectCard({ pairing, connectError, onConnect, onCancel, isStarting, canManage, t }: ConnectCardProps) {
  220. // While pairing is in flight, show the code + approval link + waiting spinner.
  221. if (pairing) {
  222. return (
  223. <Card>
  224. <CardContent className="p-8 text-center max-w-md mx-auto">
  225. <Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
  226. <h2 className="text-xl font-bold text-white mb-2">{t('profiles.orcaCloud.device.title')}</h2>
  227. <p className="text-bambu-gray mb-6">{t('profiles.orcaCloud.device.instruction')}</p>
  228. <p className="text-xs uppercase tracking-wide text-bambu-gray mb-2">
  229. {t('profiles.orcaCloud.device.codeLabel')}
  230. </p>
  231. <div className="mb-6 py-3 px-4 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg">
  232. <span className="text-2xl font-mono font-bold tracking-[0.3em] text-white select-all">
  233. {pairing.user_code}
  234. </span>
  235. </div>
  236. <a href={pairing.verification_uri_complete} target="_blank" rel="noopener noreferrer">
  237. <Button className="w-full mb-3">
  238. <ExternalLink className="w-4 h-4" />
  239. {t('profiles.orcaCloud.device.openButton')}
  240. </Button>
  241. </a>
  242. <p className="text-xs text-bambu-gray break-all mb-6">
  243. {t('profiles.orcaCloud.device.manualHint', { url: pairing.verification_uri })}
  244. </p>
  245. <div className="flex items-center justify-center gap-2 text-sm text-bambu-gray mb-4">
  246. <Loader2 className="w-4 h-4 animate-spin text-bambu-green" />
  247. {t('profiles.orcaCloud.device.waiting')}
  248. </div>
  249. <button type="button" onClick={onCancel} className="text-bambu-gray hover:text-white text-sm">
  250. {t('profiles.orcaCloud.device.cancel')}
  251. </button>
  252. </CardContent>
  253. </Card>
  254. );
  255. }
  256. return (
  257. <Card>
  258. <CardContent className="p-8 text-center">
  259. <Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
  260. <h2 className="text-xl font-bold text-white mb-2">{t('profiles.orcaCloud.connect.title')}</h2>
  261. <p className="text-bambu-gray mb-6 max-w-xl mx-auto">{t('profiles.orcaCloud.connect.description')}</p>
  262. <div className="max-w-sm mx-auto">
  263. <Button
  264. onClick={onConnect}
  265. disabled={isStarting || !canManage}
  266. title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
  267. className="w-full"
  268. >
  269. {isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
  270. {t('profiles.orcaCloud.connectButton')}
  271. </Button>
  272. {connectError && (
  273. <p className="mt-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-center gap-2">
  274. <AlertCircle className="w-4 h-4 flex-shrink-0" />
  275. {connectError}
  276. </p>
  277. )}
  278. </div>
  279. </CardContent>
  280. </Card>
  281. );
  282. }