OrcaCloudView.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. import { useEffect, 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, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react';
  5. import { api } from '../api/client';
  6. import type { OrcaOAuthProvider } 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 a paste-based PKCE handshake: backend generates the verifier and
  16. * authorize URL, the user opens it in a new tab and signs in, the browser
  17. * redirects to ``http://localhost:41172/callback`` (which fails to load since
  18. * Bambuddy isn't on the user's localhost), and the user copies the URL from
  19. * their address bar back into the paste textarea below. The backend extracts
  20. * the code, validates state for CSRF, and exchanges for tokens.
  21. *
  22. * See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking
  23. * SoftFever to broaden the Supabase redirect_to allowlist so we could ship
  24. * a clean OAuth callback instead.
  25. */
  26. export function OrcaCloudView() {
  27. const { t } = useTranslation();
  28. const queryClient = useQueryClient();
  29. const { showToast } = useToast();
  30. const { hasPermission } = useAuth();
  31. const canManage = hasPermission('orca_cloud:auth');
  32. // Paste-flow local state: once the user clicks an OAuth provider, we hold
  33. // the returned auth_url so the same URL stays clickable while they go
  34. // fetch the callback URL from their browser. ``mode`` drives which
  35. // sub-form is showing: picker → OAuth paste-flow → email/password form.
  36. const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker');
  37. const [authUrl, setAuthUrl] = useState<string | null>(null);
  38. const [pastedUrl, setPastedUrl] = useState('');
  39. const [pasteError, setPasteError] = useState<string | null>(null);
  40. const [passwordEmail, setPasswordEmail] = useState('');
  41. const [passwordValue, setPasswordValue] = useState('');
  42. const [passwordError, setPasswordError] = useState<string | null>(null);
  43. const { data: status, isLoading: statusLoading } = useQuery({
  44. queryKey: ['orcaCloudStatus'],
  45. queryFn: api.orcaCloudStatus,
  46. });
  47. const connected = !!status?.connected;
  48. const {
  49. data: profilesData,
  50. isLoading: profilesLoading,
  51. refetch: refetchProfiles,
  52. isRefetching: profilesRefetching,
  53. error: profilesError,
  54. dataUpdatedAt: profilesUpdatedAt,
  55. } = useQuery({
  56. queryKey: ['orcaCloudProfiles'],
  57. queryFn: api.orcaCloudListProfiles,
  58. enabled: connected,
  59. retry: false,
  60. staleTime: 1000 * 60 * 5,
  61. });
  62. // Configured Bambuddy printers — fed into the profile-view's printer
  63. // filter dropdown so the user can narrow profiles to a specific printer
  64. // model. Same usage as the Bambu Cloud tab.
  65. const { data: printers = [] } = useQuery({
  66. queryKey: ['printers'],
  67. queryFn: api.getPrinters,
  68. enabled: connected,
  69. });
  70. const [lastSyncTime, setLastSyncTime] = useState<Date | undefined>();
  71. useEffect(() => {
  72. if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt));
  73. }, [profilesUpdatedAt]);
  74. const startAuthMutation = useMutation({
  75. mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider),
  76. onSuccess: (data) => {
  77. setAuthUrl(data.auth_url);
  78. setPastedUrl('');
  79. setPasteError(null);
  80. setMode('paste');
  81. // Open in a new tab so the user can keep Bambuddy open in their
  82. // current tab while they sign in.
  83. window.open(data.auth_url, '_blank', 'noopener,noreferrer');
  84. },
  85. onError: (err: Error) => {
  86. showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error');
  87. },
  88. });
  89. const finishAuthMutation = useMutation({
  90. mutationFn: (url: string) => api.orcaCloudFinishAuth(url),
  91. onSuccess: (data) => {
  92. setAuthUrl(null);
  93. setPastedUrl('');
  94. setPasteError(null);
  95. setMode('picker');
  96. queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
  97. queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
  98. showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
  99. },
  100. onError: (err: Error) => {
  101. // Surface the backend's error message in the paste-error slot so the
  102. // user can fix the input (rather than a transient toast they might miss).
  103. setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed'));
  104. },
  105. });
  106. const passwordLoginMutation = useMutation({
  107. mutationFn: ({ email, password }: { email: string; password: string }) =>
  108. api.orcaCloudPasswordLogin(email, password),
  109. onSuccess: (data) => {
  110. setPasswordEmail('');
  111. setPasswordValue('');
  112. setPasswordError(null);
  113. setMode('picker');
  114. queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
  115. queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] });
  116. showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' }));
  117. },
  118. onError: (err: Error) => {
  119. setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed'));
  120. },
  121. });
  122. const logoutMutation = useMutation({
  123. mutationFn: api.orcaCloudLogout,
  124. onSuccess: () => {
  125. queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] });
  126. queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] });
  127. showToast(t('profiles.orcaCloud.toast.disconnected'));
  128. },
  129. });
  130. const handleSubmitPaste = (e: React.FormEvent) => {
  131. e.preventDefault();
  132. setPasteError(null);
  133. const trimmed = pastedUrl.trim();
  134. if (!trimmed) {
  135. setPasteError(t('profiles.orcaCloud.errors.emptyPaste'));
  136. return;
  137. }
  138. if (!trimmed.includes('code=')) {
  139. setPasteError(t('profiles.orcaCloud.errors.noCode'));
  140. return;
  141. }
  142. finishAuthMutation.mutate(trimmed);
  143. };
  144. const handleSubmitPassword = (e: React.FormEvent) => {
  145. e.preventDefault();
  146. setPasswordError(null);
  147. const email = passwordEmail.trim();
  148. if (!email || !passwordValue) {
  149. setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty'));
  150. return;
  151. }
  152. passwordLoginMutation.mutate({ email, password: passwordValue });
  153. };
  154. const resetToPicker = () => {
  155. setMode('picker');
  156. setAuthUrl(null);
  157. setPastedUrl('');
  158. setPasteError(null);
  159. setPasswordEmail('');
  160. setPasswordValue('');
  161. setPasswordError(null);
  162. };
  163. if (statusLoading) {
  164. return (
  165. <div className="flex items-center justify-center py-16">
  166. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  167. </div>
  168. );
  169. }
  170. return (
  171. <div>
  172. {connected && (
  173. <div className="flex items-center justify-between p-3 mb-6 bg-bambu-dark rounded-lg border border-bambu-dark-tertiary">
  174. <div className="flex items-center gap-3">
  175. <div className="w-2 h-2 rounded-full bg-bambu-green animate-pulse" />
  176. <span className="text-sm text-bambu-gray">
  177. {t('profiles.orcaCloud.connectedAs')}{' '}
  178. <span className="text-white">{status?.email}</span>
  179. </span>
  180. </div>
  181. <Button
  182. variant="secondary"
  183. size="sm"
  184. onClick={() => logoutMutation.mutate()}
  185. disabled={logoutMutation.isPending || !canManage}
  186. title={!canManage ? t('profiles.orcaCloud.noLogoutPermission') : undefined}
  187. >
  188. <LogOut className="w-4 h-4" />
  189. {t('profiles.orcaCloud.logout')}
  190. </Button>
  191. </div>
  192. )}
  193. {!connected ? (
  194. <ConnectFlow
  195. mode={mode}
  196. authUrl={authUrl}
  197. pastedUrl={pastedUrl}
  198. setPastedUrl={setPastedUrl}
  199. pasteError={pasteError}
  200. passwordEmail={passwordEmail}
  201. setPasswordEmail={setPasswordEmail}
  202. passwordValue={passwordValue}
  203. setPasswordValue={setPasswordValue}
  204. passwordError={passwordError}
  205. onPickProvider={(provider) => startAuthMutation.mutate(provider)}
  206. onPickPassword={() => {
  207. setMode('password');
  208. setPasswordError(null);
  209. }}
  210. onSubmitPaste={handleSubmitPaste}
  211. onSubmitPassword={handleSubmitPassword}
  212. onBack={resetToPicker}
  213. isStarting={startAuthMutation.isPending}
  214. isFinishing={finishAuthMutation.isPending}
  215. isPasswordLoading={passwordLoginMutation.isPending}
  216. canManage={canManage}
  217. t={t}
  218. />
  219. ) : profilesLoading ? (
  220. <div className="flex items-center justify-center py-16">
  221. <Loader2 className="w-8 h-8 text-bambu-green animate-spin" />
  222. </div>
  223. ) : profilesError ? (
  224. <div className="text-center py-16">
  225. <p className="text-bambu-gray mb-4">{(profilesError as Error).message}</p>
  226. <Button onClick={() => refetchProfiles()}>{t('profiles.orcaCloud.retry')}</Button>
  227. </div>
  228. ) : profilesData ? (
  229. <OrcaCloudProfilesView
  230. settings={profilesData}
  231. lastSyncTime={lastSyncTime}
  232. onRefresh={() => refetchProfiles()}
  233. isRefreshing={profilesRefetching}
  234. printers={printers}
  235. t={t}
  236. />
  237. ) : null}
  238. </div>
  239. );
  240. }
  241. interface ConnectFlowProps {
  242. mode: 'picker' | 'paste' | 'password';
  243. authUrl: string | null;
  244. pastedUrl: string;
  245. setPastedUrl: (v: string) => void;
  246. pasteError: string | null;
  247. passwordEmail: string;
  248. setPasswordEmail: (v: string) => void;
  249. passwordValue: string;
  250. setPasswordValue: (v: string) => void;
  251. passwordError: string | null;
  252. onPickProvider: (provider: OrcaOAuthProvider) => void;
  253. onPickPassword: () => void;
  254. onSubmitPaste: (e: React.FormEvent) => void;
  255. onSubmitPassword: (e: React.FormEvent) => void;
  256. onBack: () => void;
  257. isStarting: boolean;
  258. isFinishing: boolean;
  259. isPasswordLoading: boolean;
  260. canManage: boolean;
  261. t: (key: string, opts?: Record<string, string>) => string;
  262. }
  263. function ConnectFlow(props: ConnectFlowProps) {
  264. if (props.mode === 'paste' && props.authUrl) {
  265. return <PasteCard {...props} authUrl={props.authUrl} />;
  266. }
  267. if (props.mode === 'password') {
  268. return <PasswordCard {...props} />;
  269. }
  270. return <PickerCard {...props} />;
  271. }
  272. function PickerCard({
  273. onPickProvider,
  274. onPickPassword,
  275. isStarting,
  276. canManage,
  277. t,
  278. }: ConnectFlowProps) {
  279. // Orca's web sign-in offers four options: Google, Apple, GitHub (all
  280. // OAuth, paste-flow) and email+password (direct). We mirror that surface
  281. // so users with a non-Google account aren't blocked.
  282. return (
  283. <Card>
  284. <CardContent className="p-8 text-center">
  285. <Cloud className="w-12 h-12 text-bambu-green mx-auto mb-4" />
  286. <h2 className="text-xl font-bold text-white mb-2">
  287. {t('profiles.orcaCloud.connect.title')}
  288. </h2>
  289. <p className="text-bambu-gray mb-6 max-w-xl mx-auto">
  290. {t('profiles.orcaCloud.connect.description')}
  291. </p>
  292. <div className="flex flex-col gap-2 max-w-sm mx-auto">
  293. <Button
  294. onClick={onPickPassword}
  295. disabled={isStarting || !canManage}
  296. title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
  297. >
  298. <Mail className="w-4 h-4" />
  299. {t('profiles.orcaCloud.providers.email')}
  300. </Button>
  301. <Button
  302. variant="secondary"
  303. onClick={() => onPickProvider('google')}
  304. disabled={isStarting || !canManage}
  305. title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
  306. >
  307. {isStarting ? <Loader2 className="w-4 h-4 animate-spin" /> : <ExternalLink className="w-4 h-4" />}
  308. {t('profiles.orcaCloud.providers.google')}
  309. </Button>
  310. <Button
  311. variant="secondary"
  312. onClick={() => onPickProvider('github')}
  313. disabled={isStarting || !canManage}
  314. title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
  315. >
  316. <ExternalLink className="w-4 h-4" />
  317. {t('profiles.orcaCloud.providers.github')}
  318. </Button>
  319. <Button
  320. variant="secondary"
  321. onClick={() => onPickProvider('apple')}
  322. disabled={isStarting || !canManage}
  323. title={!canManage ? t('profiles.orcaCloud.noConnectPermission') : undefined}
  324. >
  325. <ExternalLink className="w-4 h-4" />
  326. {t('profiles.orcaCloud.providers.apple')}
  327. </Button>
  328. </div>
  329. </CardContent>
  330. </Card>
  331. );
  332. }
  333. function PasteCard({
  334. authUrl,
  335. pastedUrl,
  336. setPastedUrl,
  337. pasteError,
  338. onSubmitPaste,
  339. onBack,
  340. isFinishing,
  341. t,
  342. }: ConnectFlowProps & { authUrl: string }) {
  343. return (
  344. <Card>
  345. <CardContent className="p-6">
  346. <button
  347. type="button"
  348. onClick={onBack}
  349. className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
  350. >
  351. <ArrowLeft className="w-4 h-4" />
  352. {t('profiles.orcaCloud.back')}
  353. </button>
  354. <h2 className="text-xl font-bold text-white mb-4">
  355. {t('profiles.orcaCloud.paste.title')}
  356. </h2>
  357. {/* Numbered-step list with prominent visual treatment. Step 2 carries
  358. the critical "the page failing is expected" message inside an
  359. amber callout so users don't read the connection-refused page
  360. as a Bambuddy error. */}
  361. <ol className="space-y-3 mb-6">
  362. <li className="flex gap-3">
  363. <span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">1</span>
  364. <p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step1')}</p>
  365. </li>
  366. <li className="flex gap-3">
  367. <span className="flex-shrink-0 w-7 h-7 rounded-full bg-amber-100 dark:bg-amber-500/20 text-amber-700 dark:text-amber-400 text-sm font-bold flex items-center justify-center">2</span>
  368. <div className="flex-1 p-3 bg-amber-500/10 border border-amber-500/40 rounded">
  369. <div className="flex items-start gap-2">
  370. <AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
  371. <p className="text-base text-white font-medium">{t('profiles.orcaCloud.paste.step2')}</p>
  372. </div>
  373. </div>
  374. </li>
  375. <li className="flex gap-3">
  376. <span className="flex-shrink-0 w-7 h-7 rounded-full bg-bambu-dark-tertiary text-white text-sm font-bold flex items-center justify-center">3</span>
  377. <p className="text-base text-white pt-0.5">{t('profiles.orcaCloud.paste.step3')}</p>
  378. </li>
  379. </ol>
  380. <div className="mb-4 p-3 bg-bambu-dark rounded border border-bambu-dark-tertiary">
  381. <p className="text-xs text-bambu-gray mb-1">{t('profiles.orcaCloud.paste.signInUrl')}</p>
  382. <a
  383. href={authUrl}
  384. target="_blank"
  385. rel="noopener noreferrer"
  386. className="text-bambu-green text-sm break-all hover:underline"
  387. >
  388. {authUrl}
  389. </a>
  390. </div>
  391. <form onSubmit={onSubmitPaste}>
  392. <label htmlFor="orca-callback-url" className="block text-sm text-bambu-gray mb-2">
  393. {t('profiles.orcaCloud.paste.label')}
  394. </label>
  395. <textarea
  396. id="orca-callback-url"
  397. value={pastedUrl}
  398. onChange={(e) => setPastedUrl(e.target.value)}
  399. placeholder={t('profiles.orcaCloud.paste.placeholder')}
  400. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm font-mono resize-none focus:outline-none focus:border-bambu-green"
  401. rows={3}
  402. disabled={isFinishing}
  403. />
  404. {pasteError && (
  405. <p className="mt-2 text-sm text-red-700 dark:text-red-400 flex items-center gap-2">
  406. <AlertCircle className="w-4 h-4" />
  407. {pasteError}
  408. </p>
  409. )}
  410. <div className="mt-4 flex items-center gap-3">
  411. <Button type="submit" disabled={isFinishing || !pastedUrl.trim()}>
  412. {isFinishing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
  413. {t('profiles.orcaCloud.paste.submit')}
  414. </Button>
  415. </div>
  416. </form>
  417. </CardContent>
  418. </Card>
  419. );
  420. }
  421. function PasswordCard({
  422. passwordEmail,
  423. setPasswordEmail,
  424. passwordValue,
  425. setPasswordValue,
  426. passwordError,
  427. onSubmitPassword,
  428. onBack,
  429. isPasswordLoading,
  430. t,
  431. }: ConnectFlowProps) {
  432. return (
  433. <Card>
  434. <CardContent className="p-6 max-w-md mx-auto">
  435. <button
  436. type="button"
  437. onClick={onBack}
  438. className="text-bambu-gray hover:text-white text-sm flex items-center gap-1 mb-4"
  439. >
  440. <ArrowLeft className="w-4 h-4" />
  441. {t('profiles.orcaCloud.back')}
  442. </button>
  443. <h2 className="text-xl font-bold text-white mb-4">
  444. {t('profiles.orcaCloud.password.title')}
  445. </h2>
  446. <form onSubmit={onSubmitPassword} className="space-y-4">
  447. <div>
  448. <label htmlFor="orca-password-email" className="block text-sm text-bambu-gray mb-1">
  449. {t('profiles.orcaCloud.password.email')}
  450. </label>
  451. <input
  452. id="orca-password-email"
  453. type="email"
  454. value={passwordEmail}
  455. onChange={(e) => setPasswordEmail(e.target.value)}
  456. placeholder={t('profiles.orcaCloud.password.emailPlaceholder')}
  457. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
  458. disabled={isPasswordLoading}
  459. autoComplete="email"
  460. />
  461. </div>
  462. <div>
  463. <label htmlFor="orca-password-value" className="block text-sm text-bambu-gray mb-1">
  464. {t('profiles.orcaCloud.password.password')}
  465. </label>
  466. <input
  467. id="orca-password-value"
  468. type="password"
  469. value={passwordValue}
  470. onChange={(e) => setPasswordValue(e.target.value)}
  471. className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded text-white text-sm focus:outline-none focus:border-bambu-green"
  472. disabled={isPasswordLoading}
  473. autoComplete="current-password"
  474. />
  475. </div>
  476. {passwordError && (
  477. <p className="text-sm text-red-700 dark:text-red-400 flex items-center gap-2">
  478. <AlertCircle className="w-4 h-4" />
  479. {passwordError}
  480. </p>
  481. )}
  482. <Button type="submit" disabled={isPasswordLoading || !passwordEmail.trim() || !passwordValue}>
  483. {isPasswordLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
  484. {t('profiles.orcaCloud.password.submit')}
  485. </Button>
  486. </form>
  487. </CardContent>
  488. </Card>
  489. );
  490. }