import { useEffect, useState } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { Cloud, ExternalLink, LogOut, Loader2, AlertCircle, AlertTriangle, Check, Mail, ArrowLeft } from 'lucide-react'; import { api } from '../api/client'; import type { OrcaOAuthProvider } from '../api/client'; import { Card, CardContent } from './Card'; import { Button } from './Button'; import { useToast } from '../contexts/ToastContext'; import { useAuth } from '../contexts/AuthContext'; import { OrcaCloudProfilesView } from './OrcaCloudProfilesView'; /** * Orca Cloud profile sync tab. * * Auth uses a paste-based PKCE handshake: backend generates the verifier and * authorize URL, the user opens it in a new tab and signs in, the browser * redirects to ``http://localhost:41172/callback`` (which fails to load since * Bambuddy isn't on the user's localhost), and the user copies the URL from * their address bar back into the paste textarea below. The backend extracts * the code, validates state for CSRF, and exchanges for tokens. * * See OrcaSlicer/OrcaSlicer#14028 for the open feature request asking * SoftFever to broaden the Supabase redirect_to allowlist so we could ship * a clean OAuth callback instead. */ export function OrcaCloudView() { const { t } = useTranslation(); const queryClient = useQueryClient(); const { showToast } = useToast(); const { hasPermission } = useAuth(); const canManage = hasPermission('orca_cloud:auth'); // Paste-flow local state: once the user clicks an OAuth provider, we hold // the returned auth_url so the same URL stays clickable while they go // fetch the callback URL from their browser. ``mode`` drives which // sub-form is showing: picker → OAuth paste-flow → email/password form. const [mode, setMode] = useState<'picker' | 'paste' | 'password'>('picker'); const [authUrl, setAuthUrl] = useState(null); const [pastedUrl, setPastedUrl] = useState(''); const [pasteError, setPasteError] = useState(null); const [passwordEmail, setPasswordEmail] = useState(''); const [passwordValue, setPasswordValue] = useState(''); const [passwordError, setPasswordError] = useState(null); const { data: status, isLoading: statusLoading } = useQuery({ queryKey: ['orcaCloudStatus'], queryFn: api.orcaCloudStatus, }); const connected = !!status?.connected; const { data: profilesData, isLoading: profilesLoading, refetch: refetchProfiles, isRefetching: profilesRefetching, error: profilesError, dataUpdatedAt: profilesUpdatedAt, } = useQuery({ queryKey: ['orcaCloudProfiles'], queryFn: api.orcaCloudListProfiles, enabled: connected, retry: false, staleTime: 1000 * 60 * 5, }); // Configured Bambuddy printers — fed into the profile-view's printer // filter dropdown so the user can narrow profiles to a specific printer // model. Same usage as the Bambu Cloud tab. const { data: printers = [] } = useQuery({ queryKey: ['printers'], queryFn: api.getPrinters, enabled: connected, }); const [lastSyncTime, setLastSyncTime] = useState(); useEffect(() => { if (profilesUpdatedAt) setLastSyncTime(new Date(profilesUpdatedAt)); }, [profilesUpdatedAt]); const startAuthMutation = useMutation({ mutationFn: (provider: OrcaOAuthProvider) => api.orcaCloudStartAuth(provider), onSuccess: (data) => { setAuthUrl(data.auth_url); setPastedUrl(''); setPasteError(null); setMode('paste'); // Open in a new tab so the user can keep Bambuddy open in their // current tab while they sign in. window.open(data.auth_url, '_blank', 'noopener,noreferrer'); }, onError: (err: Error) => { showToast(err.message || t('profiles.orcaCloud.errors.startFailed'), 'error'); }, }); const finishAuthMutation = useMutation({ mutationFn: (url: string) => api.orcaCloudFinishAuth(url), onSuccess: (data) => { setAuthUrl(null); setPastedUrl(''); setPasteError(null); setMode('picker'); queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] }); showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' })); }, onError: (err: Error) => { // Surface the backend's error message in the paste-error slot so the // user can fix the input (rather than a transient toast they might miss). setPasteError(err.message || t('profiles.orcaCloud.errors.finishFailed')); }, }); const passwordLoginMutation = useMutation({ mutationFn: ({ email, password }: { email: string; password: string }) => api.orcaCloudPasswordLogin(email, password), onSuccess: (data) => { setPasswordEmail(''); setPasswordValue(''); setPasswordError(null); setMode('picker'); queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); queryClient.invalidateQueries({ queryKey: ['orcaCloudProfiles'] }); showToast(t('profiles.orcaCloud.toast.connected', { email: data.email || '' })); }, onError: (err: Error) => { setPasswordError(err.message || t('profiles.orcaCloud.errors.passwordFailed')); }, }); const logoutMutation = useMutation({ mutationFn: api.orcaCloudLogout, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['orcaCloudStatus'] }); queryClient.removeQueries({ queryKey: ['orcaCloudProfiles'] }); showToast(t('profiles.orcaCloud.toast.disconnected')); }, }); const handleSubmitPaste = (e: React.FormEvent) => { e.preventDefault(); setPasteError(null); const trimmed = pastedUrl.trim(); if (!trimmed) { setPasteError(t('profiles.orcaCloud.errors.emptyPaste')); return; } if (!trimmed.includes('code=')) { setPasteError(t('profiles.orcaCloud.errors.noCode')); return; } finishAuthMutation.mutate(trimmed); }; const handleSubmitPassword = (e: React.FormEvent) => { e.preventDefault(); setPasswordError(null); const email = passwordEmail.trim(); if (!email || !passwordValue) { setPasswordError(t('profiles.orcaCloud.errors.passwordEmpty')); return; } passwordLoginMutation.mutate({ email, password: passwordValue }); }; const resetToPicker = () => { setMode('picker'); setAuthUrl(null); setPastedUrl(''); setPasteError(null); setPasswordEmail(''); setPasswordValue(''); setPasswordError(null); }; if (statusLoading) { return (
); } return (
{connected && (
{t('profiles.orcaCloud.connectedAs')}{' '} {status?.email}
)} {!connected ? ( startAuthMutation.mutate(provider)} onPickPassword={() => { setMode('password'); setPasswordError(null); }} onSubmitPaste={handleSubmitPaste} onSubmitPassword={handleSubmitPassword} onBack={resetToPicker} isStarting={startAuthMutation.isPending} isFinishing={finishAuthMutation.isPending} isPasswordLoading={passwordLoginMutation.isPending} canManage={canManage} t={t} /> ) : profilesLoading ? (
) : profilesError ? (

{(profilesError as Error).message}

) : profilesData ? ( refetchProfiles()} isRefreshing={profilesRefetching} printers={printers} t={t} /> ) : null}
); } interface ConnectFlowProps { mode: 'picker' | 'paste' | 'password'; authUrl: string | null; pastedUrl: string; setPastedUrl: (v: string) => void; pasteError: string | null; passwordEmail: string; setPasswordEmail: (v: string) => void; passwordValue: string; setPasswordValue: (v: string) => void; passwordError: string | null; onPickProvider: (provider: OrcaOAuthProvider) => void; onPickPassword: () => void; onSubmitPaste: (e: React.FormEvent) => void; onSubmitPassword: (e: React.FormEvent) => void; onBack: () => void; isStarting: boolean; isFinishing: boolean; isPasswordLoading: boolean; canManage: boolean; t: (key: string, opts?: Record) => string; } function ConnectFlow(props: ConnectFlowProps) { if (props.mode === 'paste' && props.authUrl) { return ; } if (props.mode === 'password') { return ; } return ; } function PickerCard({ onPickProvider, onPickPassword, isStarting, canManage, t, }: ConnectFlowProps) { // Orca's web sign-in offers four options: Google, Apple, GitHub (all // OAuth, paste-flow) and email+password (direct). We mirror that surface // so users with a non-Google account aren't blocked. return (

{t('profiles.orcaCloud.connect.title')}

{t('profiles.orcaCloud.connect.description')}

); } function PasteCard({ authUrl, pastedUrl, setPastedUrl, pasteError, onSubmitPaste, onBack, isFinishing, t, }: ConnectFlowProps & { authUrl: string }) { return (

{t('profiles.orcaCloud.paste.title')}

{/* Numbered-step list with prominent visual treatment. Step 2 carries the critical "the page failing is expected" message inside an amber callout so users don't read the connection-refused page as a Bambuddy error. */}
  1. 1

    {t('profiles.orcaCloud.paste.step1')}

  2. 2

    {t('profiles.orcaCloud.paste.step2')}

  3. 3

    {t('profiles.orcaCloud.paste.step3')}

{t('profiles.orcaCloud.paste.signInUrl')}

{authUrl}