diff --git a/client/src/components/Settings/PhotoProvidersSection.tsx b/client/src/components/Settings/PhotoProvidersSection.tsx index 0aa8ceaed..4c58bd7e8 100644 --- a/client/src/components/Settings/PhotoProvidersSection.tsx +++ b/client/src/components/Settings/PhotoProvidersSection.tsx @@ -1,219 +1,33 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import { Camera, Loader2, Plug, Save } from 'lucide-react' import { useTranslation } from '../../i18n' -import { useToast } from '../../components/shared/Toast' -import apiClient from '../../api/client' -import { useAddonStore } from '../../store/addonStore' import Section from './Section' import ToggleSwitch from './ToggleSwitch' import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, StatusPill } from './settingsKit' import { EditorField, INPUT } from '../shared/dialogParts' import { fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' - -interface ProviderField { - key: string - label: string - input_type: string - placeholder?: string | null - hint?: string | null - required: boolean - secret: boolean - settings_key?: string | null - payload_key?: string | null - sort_order: number -} - -interface PhotoProviderAddon { - id: string - name: string - type: string - enabled: boolean - config?: Record - fields?: ProviderField[] -} - -interface ProviderConfig { - settings_get?: string - settings_put?: string - status_get?: string - test_get?: string - test_post?: string -} - -const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { - const raw = provider.config || {} - return { - settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, - settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, - status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, - test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, - test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, - } -} - -const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { - return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) -} +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from './usePhotoProviderConnections' export default function PhotoProvidersSection(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const { isEnabled: addonEnabled, addons } = useAddonStore() - const memoriesEnabled = addonEnabled('memories') - - const [saving, setSaving] = useState>({}) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) - - const activePhotoProviders = useMemo( - () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], - [addons], - ) - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - for (const field of getProviderFields(provider)) { - const payloadKey = field.payload_key || field.settings_key || field.key - if (field.input_type === 'checkbox') { - payload[payloadKey] = values[field.key] === 'true' - continue - } - const value = (values[field.key] || '').trim() - if (field.secret && !value) continue - payload[payloadKey] = value - } - return payload - } - - const refreshProviderConnection = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const statusPath = cfg.status_get - if (!statusPath) return - try { - const res = await apiClient.get(statusPath) - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) - } catch { - setProviderConnected(prev => ({ ...prev, [provider.id]: false })) - } - } - - const activeProviderSignature = useMemo( - () => activePhotoProviders.map(provider => provider.id).join('|'), - [activePhotoProviders], - ) - - useEffect(() => { - let isCancelled = false - - for (const provider of activePhotoProviders) { - const cfg = getProviderConfig(provider) - const fields = getProviderFields(provider) - - // Seed checkbox defaults before the async settings load resolves - const checkboxDefaults: Record = {} - for (const field of fields) { - if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false' - } - if (Object.keys(checkboxDefaults).length > 0) { - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) }, - })) - } - - if (cfg.settings_get) { - apiClient.get(cfg.settings_get).then(res => { - if (isCancelled) return - - const nextValues: Record = {} - for (const field of fields) { - // Do not prefill secret fields; user can overwrite only when needed. - if (field.secret) continue - const sourceKey = field.settings_key || field.payload_key || field.key - const rawValue = (res.data as Record)[sourceKey] - if (rawValue != null) { - nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue) - } else if (field.input_type === 'checkbox') { - nextValues[field.key] = 'false' - } else { - nextValues[field.key] = '' - } - } - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, - })) - if (typeof res.data?.connected === 'boolean') { - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) - } - }).catch(() => { }) - } - - refreshProviderConnection(provider).catch(() => { }) - } - - return () => { - isCancelled = true - } - }, [activePhotoProviders, activeProviderSignature]) - - const handleProviderFieldChange = (providerId: string, key: string, value: string) => { - setProviderValues(prev => ({ - ...prev, - [providerId]: { ...(prev[providerId] || {}), [key]: value }, - })) - } - - const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { - const values = providerValues[provider.id] || {} - return getProviderFields(provider).some(field => { - if (!field.required) return false - return !(values[field.key] || '').trim() - }) - } - - const handleSaveProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - if (!cfg.settings_put) return - setSaving(s => ({ ...s, [provider.id]: true })) - try { - await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) - await refreshProviderConnection(provider) - toast.success(t('memories.saved', { provider_name: provider.name })) - } catch { - toast.error(t('memories.saveError', { provider_name: provider.name })) - } finally { - setSaving(s => ({ ...s, [provider.id]: false })) - } - } - - const handleTestProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const testPath = cfg.test_post || cfg.test_get || cfg.status_get - if (!testPath) return - setProviderTesting(prev => ({ ...prev, [provider.id]: true })) - try { - // Only a POST probe carries the form values. A provider that declares just a - // GET (test_get / status_get) is probed against its saved credentials. - const res = cfg.test_post - ? await apiClient.post(testPath, buildProviderPayload(provider)) - : await apiClient.get(testPath) - const ok = !!res.data?.connected - setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) - if (ok) { - toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) - } else { - toast.error(`${t('memories.connectionError', { provider_name: provider.name })} ${res.data?.error ? `: ${String(res.data.error)}` : ''}`) - } - } catch { - toast.error(t('memories.connectionError', { provider_name: provider.name })) - } finally { - setProviderTesting(prev => ({ ...prev, [provider.id]: false })) - } - } + const { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + } = usePhotoProviderConnections({ statusRouteOwnsBadge: false, testErrorGap: ' ' }) const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { const fields = getProviderFields(provider) diff --git a/client/src/components/Settings/usePhotoProviderConnections.test.ts b/client/src/components/Settings/usePhotoProviderConnections.test.ts new file mode 100644 index 000000000..1c5fe6e93 --- /dev/null +++ b/client/src/components/Settings/usePhotoProviderConnections.test.ts @@ -0,0 +1,233 @@ +// FE-COMP-PHOTOCONN-001 to -010: the photo provider connection logic both settings shells share. +import { act, renderHook, waitFor } from '@testing-library/react'; +import { resetAllStores, seedStore } from '../../../tests/helpers/store'; +import apiClient from '../../api/client'; +import { useAddonStore } from '../../store/addonStore'; +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from './usePhotoProviderConnections'; + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ + useTranslation: () => ({ + t: (k: string, p?: Record) => (p ? `${k}(${String(p.provider_name)})` : k), + }), +})); + +const PROVIDER: PhotoProviderAddon = { + id: 'immich', + name: 'Immich', + type: 'photo_provider', + enabled: true, + config: { + settings_get: '/addons/immich/settings', + settings_put: '/addons/immich/settings', + status_get: '/addons/immich/status', + test_post: '/addons/immich/test', + }, + fields: [ + { + key: 'api_key', + label: 'api_key', + input_type: 'text', + required: true, + secret: true, + settings_key: 'api_key', + payload_key: 'api_key', + sort_order: 1, + }, + { + key: 'url', + label: 'url', + input_type: 'text', + required: true, + secret: false, + settings_key: 'url', + payload_key: 'url', + sort_order: 0, + }, + { + key: 'shared', + label: 'shared', + input_type: 'checkbox', + required: false, + secret: false, + settings_key: 'include_shared', + payload_key: null, + sort_order: 2, + }, + ], +}; + +type Resp = { data: Record }; + +function seedProviders(providers: PhotoProviderAddon[], memories = true) { + seedStore(useAddonStore, { + addons: providers, + isEnabled: (id: string) => memories && id === 'memories', + }); +} + +/** Answers GETs by path, so the settings read and the status read can be told apart. */ +function stubGets(byPath: Record Promise)>) { + return vi.spyOn(apiClient, 'get').mockImplementation((url: string) => { + const hit = byPath[url]; + if (!hit) return Promise.reject(new Error(`unexpected GET ${url}`)); + return typeof hit === 'function' ? hit() : Promise.resolve(hit); + }); +} + +function render(statusRouteOwnsBadge: boolean, testErrorGap = '') { + return renderHook(() => usePhotoProviderConnections({ statusRouteOwnsBadge, testErrorGap })); +} + +beforeEach(() => { + resetAllStores(); + vi.clearAllMocks(); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('photo provider helpers', () => { + it('FE-COMP-PHOTOCONN-001: config keeps only string routes and fields come sorted', () => { + expect(getProviderConfig({ ...PROVIDER, config: { settings_get: '/s', status_get: 5 } })).toEqual({ + settings_get: '/s', + settings_put: undefined, + status_get: undefined, + test_get: undefined, + test_post: undefined, + }); + expect(getProviderFields(PROVIDER).map((f) => f.key)).toEqual(['url', 'api_key', 'shared']); + }); +}); + +describe('usePhotoProviderConnections', () => { + it('FE-COMP-PHOTOCONN-002: only enabled photo providers count, and memories gates the section', () => { + seedProviders( + [PROVIDER, { ...PROVIDER, id: 'off', enabled: false }, { ...PROVIDER, id: 'x', type: 'other' }], + false + ); + stubGets({}); + const { result } = render(true); + expect(result.current.memoriesEnabled).toBe(false); + expect(result.current.activePhotoProviders.map((p) => p.id)).toEqual(['immich']); + }); + + it('FE-COMP-PHOTOCONN-003: hydrates the non secret fields, maps checkbox values and leaves the secret blank', async () => { + seedProviders([PROVIDER]); + stubGets({ + '/addons/immich/settings': { data: { url: 'https://p.example', api_key: 'secret', include_shared: true } }, + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://p.example')); + expect(result.current.providerValues.immich).toEqual({ url: 'https://p.example', shared: 'true' }); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + }); + + it('FE-COMP-PHOTOCONN-004: with the status route owning the badge, a late settings read does not override it', async () => { + seedProviders([PROVIDER]); + let resolveSettings!: (r: Resp) => void; + stubGets({ + '/addons/immich/settings': () => new Promise((r) => (resolveSettings = r)), + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + await act(async () => resolveSettings({ data: { url: 'u', connected: false } })); + expect(result.current.providerValues.immich?.url).toBe('u'); + expect(result.current.providerConnected.immich).toBe(true); + }); + + it('FE-COMP-PHOTOCONN-005: without that, the later settings read sets the badge', async () => { + seedProviders([PROVIDER]); + let resolveSettings!: (r: Resp) => void; + stubGets({ + '/addons/immich/settings': () => new Promise((r) => (resolveSettings = r)), + '/addons/immich/status': { data: { connected: true } }, + }); + const { result } = render(false); + await waitFor(() => expect(result.current.providerConnected.immich).toBe(true)); + await act(async () => resolveSettings({ data: { url: 'u', connected: false } })); + expect(result.current.providerConnected.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-006: save is blocked while a required field is blank', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('')); + expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(true); + act(() => result.current.handleProviderFieldChange('immich', 'url', 'https://a')); + act(() => result.current.handleProviderFieldChange('immich', 'api_key', 'k')); + expect(result.current.isProviderSaveDisabled(PROVIDER)).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-007: save puts the payload without a blank secret, rechecks the status and toasts', async () => { + seedProviders([PROVIDER]); + const get = stubGets({ + '/addons/immich/settings': { data: { url: 'https://a' } }, + '/addons/immich/status': { data: { connected: false } }, + }); + const put = vi.spyOn(apiClient, 'put').mockResolvedValue({ data: {} }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a')); + act(() => result.current.handleProviderFieldChange('immich', 'url', ' https://b ')); + get.mockClear(); + await act(() => result.current.handleSaveProvider(PROVIDER)); + expect(put).toHaveBeenCalledWith('/addons/immich/settings', { url: 'https://b', include_shared: false }); + expect(get).toHaveBeenCalledWith('/addons/immich/status'); + expect(toast.success).toHaveBeenCalledWith('memories.saved(Immich)'); + expect(result.current.saving.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-008: a failed save toasts the error and a provider without a save route does nothing', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + const put = vi.spyOn(apiClient, 'put').mockRejectedValue(new Error('x')); + const { result } = render(true); + await act(() => result.current.handleSaveProvider(PROVIDER)); + expect(toast.error).toHaveBeenCalledWith('memories.saveError(Immich)'); + put.mockClear(); + await act(() => result.current.handleSaveProvider({ ...PROVIDER, config: {} })); + expect(put).not.toHaveBeenCalled(); + }); + + it('FE-COMP-PHOTOCONN-009: a POST test sends the form, a GET test only probes the saved credentials', async () => { + seedProviders([PROVIDER]); + const get = stubGets({ + '/addons/immich/settings': { data: { url: 'https://a' } }, + '/addons/immich/status': { data: {} }, + }); + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: true } }); + const { result } = render(true); + await waitFor(() => expect(result.current.providerValues.immich?.url).toBe('https://a')); + await act(() => result.current.handleTestProvider(PROVIDER)); + expect(post).toHaveBeenCalledWith('/addons/immich/test', { url: 'https://a', include_shared: false }); + expect(result.current.providerConnected.immich).toBe(true); + expect(toast.success).toHaveBeenCalledWith('memories.connectionSuccess(Immich)'); + get.mockClear(); + const getOnly = { ...PROVIDER, config: { status_get: '/addons/immich/status' } }; + await act(() => result.current.handleTestProvider(getOnly)); + expect(get).toHaveBeenCalledWith('/addons/immich/status'); + expect(result.current.providerTesting.immich).toBe(false); + }); + + it('FE-COMP-PHOTOCONN-010: a failed probe joins the server reason with the gap each shell asks for', async () => { + seedProviders([PROVIDER]); + stubGets({ '/addons/immich/settings': { data: {} }, '/addons/immich/status': { data: {} } }); + vi.spyOn(apiClient, 'post').mockResolvedValue({ data: { connected: false, error: 'bad key' } }); + const phone = render(true, ''); + await act(() => phone.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich): bad key'); + const desktop = render(false, ' '); + await act(() => desktop.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich) : bad key'); + vi.mocked(apiClient.post).mockRejectedValue(new Error('x')); + await act(() => desktop.result.current.handleTestProvider(PROVIDER)); + expect(toast.error).toHaveBeenLastCalledWith('memories.connectionError(Immich)'); + }); +}); diff --git a/client/src/components/Settings/usePhotoProviderConnections.ts b/client/src/components/Settings/usePhotoProviderConnections.ts new file mode 100644 index 000000000..98ff592c9 --- /dev/null +++ b/client/src/components/Settings/usePhotoProviderConnections.ts @@ -0,0 +1,244 @@ +import { useEffect, useMemo, useState } from 'react'; +import apiClient from '../../api/client'; +import { useTranslation } from '../../i18n'; +import { useAddonStore } from '../../store/addonStore'; +import { useToast } from '../shared/Toast'; + +export interface ProviderField { + key: string; + label: string; + input_type: string; + placeholder?: string | null; + hint?: string | null; + required: boolean; + secret: boolean; + settings_key?: string | null; + payload_key?: string | null; + sort_order: number; +} + +export interface PhotoProviderAddon { + id: string; + name: string; + type: string; + enabled: boolean; + config?: Record; + fields?: ProviderField[]; +} + +export interface ProviderConfig { + settings_get?: string; + settings_put?: string; + status_get?: string; + test_get?: string; + test_post?: string; +} + +export const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { + const raw = provider.config || {}; + return { + settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, + settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, + status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, + test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, + test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, + }; +}; + +export const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { + return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order); +}; + +export interface PhotoProviderConnectionOptions { + /** + * When the provider has a status route, let only that route set the badge and + * ignore the `connected` flag of the settings read, so the two responses cannot + * race. The phone does this; the desktop takes whichever answer lands last. + */ + statusRouteOwnsBadge: boolean; + /** What goes between the failed test sentence and the server's ": reason" ('' on the phone, ' ' on the desktop). */ + testErrorGap: string; +} + +/** + * The photo provider connection cards behind both settings shells: every enabled + * photo_provider addon with its declared fields, seeded and hydrated values + * (secrets never prefilled), the connection badge, and save and test. The desktop + * section and its phone twin render their own markup over this. + */ +export function usePhotoProviderConnections({ statusRouteOwnsBadge, testErrorGap }: PhotoProviderConnectionOptions) { + const { t } = useTranslation(); + const toast = useToast(); + const { isEnabled: addonEnabled, addons } = useAddonStore(); + const memoriesEnabled = addonEnabled('memories'); + + const [saving, setSaving] = useState>({}); + const [providerValues, setProviderValues] = useState>>({}); + const [providerConnected, setProviderConnected] = useState>({}); + const [providerTesting, setProviderTesting] = useState>({}); + + const activePhotoProviders = useMemo( + () => addons.filter((a) => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], + [addons] + ); + + const buildProviderPayload = (provider: PhotoProviderAddon): Record => { + const values = providerValues[provider.id] || {}; + const payload: Record = {}; + for (const field of getProviderFields(provider)) { + const payloadKey = field.payload_key || field.settings_key || field.key; + if (field.input_type === 'checkbox') { + payload[payloadKey] = values[field.key] === 'true'; + continue; + } + const value = (values[field.key] || '').trim(); + if (field.secret && !value) continue; + payload[payloadKey] = value; + } + return payload; + }; + + const refreshProviderConnection = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider); + const statusPath = cfg.status_get; + if (!statusPath) return; + try { + const res = await apiClient.get(statusPath); + setProviderConnected((prev) => ({ ...prev, [provider.id]: !!res.data?.connected })); + } catch { + setProviderConnected((prev) => ({ ...prev, [provider.id]: false })); + } + }; + + const activeProviderSignature = useMemo( + () => activePhotoProviders.map((provider) => provider.id).join('|'), + [activePhotoProviders] + ); + + useEffect(() => { + let isCancelled = false; + + for (const provider of activePhotoProviders) { + const cfg = getProviderConfig(provider); + const fields = getProviderFields(provider); + + // Seed checkbox defaults before the async settings load resolves + const checkboxDefaults: Record = {}; + for (const field of fields) { + if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false'; + } + if (Object.keys(checkboxDefaults).length > 0) { + setProviderValues((prev) => ({ + ...prev, + [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) }, + })); + } + + if (cfg.settings_get) { + apiClient + .get(cfg.settings_get) + .then((res) => { + if (isCancelled) return; + + const nextValues: Record = {}; + for (const field of fields) { + // Do not prefill secret fields; user can overwrite only when needed. + if (field.secret) continue; + const sourceKey = field.settings_key || field.payload_key || field.key; + const rawValue = (res.data as Record)[sourceKey]; + if (rawValue != null) { + nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue); + } else if (field.input_type === 'checkbox') { + nextValues[field.key] = 'false'; + } else { + nextValues[field.key] = ''; + } + } + setProviderValues((prev) => ({ + ...prev, + [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, + })); + if (!(statusRouteOwnsBadge && cfg.status_get) && typeof res.data?.connected === 'boolean') { + setProviderConnected((prev) => ({ ...prev, [provider.id]: !!res.data.connected })); + } + }) + .catch(() => {}); + } + + refreshProviderConnection(provider).catch(() => {}); + } + + return () => { + isCancelled = true; + }; + }, [activePhotoProviders, activeProviderSignature, statusRouteOwnsBadge]); + + const handleProviderFieldChange = (providerId: string, key: string, value: string) => { + setProviderValues((prev) => ({ + ...prev, + [providerId]: { ...(prev[providerId] || {}), [key]: value }, + })); + }; + + const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { + const values = providerValues[provider.id] || {}; + return getProviderFields(provider).some((field) => { + if (!field.required) return false; + return !(values[field.key] || '').trim(); + }); + }; + + const handleSaveProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider); + if (!cfg.settings_put) return; + setSaving((s) => ({ ...s, [provider.id]: true })); + try { + await apiClient.put(cfg.settings_put, buildProviderPayload(provider)); + await refreshProviderConnection(provider); + toast.success(t('memories.saved', { provider_name: provider.name })); + } catch { + toast.error(t('memories.saveError', { provider_name: provider.name })); + } finally { + setSaving((s) => ({ ...s, [provider.id]: false })); + } + }; + + const handleTestProvider = async (provider: PhotoProviderAddon) => { + const cfg = getProviderConfig(provider); + const testPath = cfg.test_post || cfg.test_get || cfg.status_get; + if (!testPath) return; + setProviderTesting((prev) => ({ ...prev, [provider.id]: true })); + try { + // Only a POST probe carries the form values. A provider that declares just a + // GET (test_get / status_get) is probed against its saved credentials. + const res = cfg.test_post + ? await apiClient.post(testPath, buildProviderPayload(provider)) + : await apiClient.get(testPath); + const ok = !!res.data?.connected; + setProviderConnected((prev) => ({ ...prev, [provider.id]: ok })); + if (ok) { + toast.success(t('memories.connectionSuccess', { provider_name: provider.name })); + } else { + const detail = res.data?.error ? `: ${String(res.data.error)}` : ''; + toast.error(`${t('memories.connectionError', { provider_name: provider.name })}${testErrorGap}${detail}`); + } + } catch { + toast.error(t('memories.connectionError', { provider_name: provider.name })); + } finally { + setProviderTesting((prev) => ({ ...prev, [provider.id]: false })); + } + }; + + return { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + }; +} diff --git a/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx b/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx index a494e733c..5d7be597a 100644 --- a/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx +++ b/client/src/mobile/screens/settings/MPhotoProvidersSection.tsx @@ -1,11 +1,14 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React from 'react' import { Camera, Save, RefreshCw } from 'lucide-react' import { useTranslation } from '../../../i18n' -import { useToast } from '../../../components/shared/Toast' -import apiClient from '../../../api/client' -import { useAddonStore } from '../../../store/addonStore' import { MSetCard, MSetEyebrow, MSetRow, MSetInput, MSetButton, MSetHint } from './MSettingsUi' import MToggle from '../../components/MToggle' +import { + getProviderConfig, + getProviderFields, + usePhotoProviderConnections, + type PhotoProviderAddon, +} from '../../../components/Settings/usePhotoProviderConnections' /** * Mobile-native twin of components/Settings/PhotoProvidersSection. Same logic @@ -14,209 +17,20 @@ import MToggle from '../../components/MToggle' * system with MToggle switches. Presentation only — the behaviour is identical. */ -interface ProviderField { - key: string - label: string - input_type: string - placeholder?: string | null - hint?: string | null - required: boolean - secret: boolean - settings_key?: string | null - payload_key?: string | null - sort_order: number -} - -interface PhotoProviderAddon { - id: string - name: string - type: string - enabled: boolean - config?: Record - fields?: ProviderField[] -} - -interface ProviderConfig { - settings_get?: string - settings_put?: string - status_get?: string - test_get?: string - test_post?: string -} - -const getProviderConfig = (provider: PhotoProviderAddon): ProviderConfig => { - const raw = provider.config || {} - return { - settings_get: typeof raw.settings_get === 'string' ? raw.settings_get : undefined, - settings_put: typeof raw.settings_put === 'string' ? raw.settings_put : undefined, - status_get: typeof raw.status_get === 'string' ? raw.status_get : undefined, - test_get: typeof raw.test_get === 'string' ? raw.test_get : undefined, - test_post: typeof raw.test_post === 'string' ? raw.test_post : undefined, - } -} - -const getProviderFields = (provider: PhotoProviderAddon): ProviderField[] => { - return [...(provider.fields || [])].sort((a, b) => a.sort_order - b.sort_order) -} - export default function MPhotoProvidersSection(): React.ReactElement { const { t } = useTranslation() - const toast = useToast() - const { isEnabled: addonEnabled, addons } = useAddonStore() - const memoriesEnabled = addonEnabled('memories') - - const [saving, setSaving] = useState>({}) - const [providerValues, setProviderValues] = useState>>({}) - const [providerConnected, setProviderConnected] = useState>({}) - const [providerTesting, setProviderTesting] = useState>({}) - - const activePhotoProviders = useMemo( - () => addons.filter(a => a.type === 'photo_provider' && a.enabled) as PhotoProviderAddon[], - [addons], - ) - - const buildProviderPayload = (provider: PhotoProviderAddon): Record => { - const values = providerValues[provider.id] || {} - const payload: Record = {} - for (const field of getProviderFields(provider)) { - const payloadKey = field.payload_key || field.settings_key || field.key - if (field.input_type === 'checkbox') { - payload[payloadKey] = values[field.key] === 'true' - continue - } - const value = (values[field.key] || '').trim() - if (field.secret && !value) continue - payload[payloadKey] = value - } - return payload - } - - const refreshProviderConnection = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const statusPath = cfg.status_get - if (!statusPath) return - try { - const res = await apiClient.get(statusPath) - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data?.connected })) - } catch { - setProviderConnected(prev => ({ ...prev, [provider.id]: false })) - } - } - - const activeProviderSignature = useMemo( - () => activePhotoProviders.map(provider => provider.id).join('|'), - [activePhotoProviders], - ) - - useEffect(() => { - let isCancelled = false - - for (const provider of activePhotoProviders) { - const cfg = getProviderConfig(provider) - const fields = getProviderFields(provider) - - // Seed checkbox defaults before the async settings load resolves - const checkboxDefaults: Record = {} - for (const field of fields) { - if (field.input_type === 'checkbox') checkboxDefaults[field.key] = 'false' - } - if (Object.keys(checkboxDefaults).length > 0) { - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...checkboxDefaults, ...(prev[provider.id] || {}) }, - })) - } - - if (cfg.settings_get) { - apiClient.get(cfg.settings_get).then(res => { - if (isCancelled) return - - const nextValues: Record = {} - for (const field of fields) { - // Do not prefill secret fields; user can overwrite only when needed. - if (field.secret) continue - const sourceKey = field.settings_key || field.payload_key || field.key - const rawValue = (res.data as Record)[sourceKey] - if (rawValue != null) { - nextValues[field.key] = typeof rawValue === 'string' ? rawValue : String(rawValue) - } else if (field.input_type === 'checkbox') { - nextValues[field.key] = 'false' - } else { - nextValues[field.key] = '' - } - } - setProviderValues(prev => ({ - ...prev, - [provider.id]: { ...(prev[provider.id] || {}), ...nextValues }, - })) - // The status route owns the badge when there is one — otherwise the two - // responses would race and the later one would win. - if (!cfg.status_get && typeof res.data?.connected === 'boolean') { - setProviderConnected(prev => ({ ...prev, [provider.id]: !!res.data.connected })) - } - }).catch(() => { }) - } - - refreshProviderConnection(provider).catch(() => { }) - } - - return () => { - isCancelled = true - } - }, [activePhotoProviders, activeProviderSignature]) - - const handleProviderFieldChange = (providerId: string, key: string, value: string) => { - setProviderValues(prev => ({ - ...prev, - [providerId]: { ...(prev[providerId] || {}), [key]: value }, - })) - } - - const isProviderSaveDisabled = (provider: PhotoProviderAddon): boolean => { - const values = providerValues[provider.id] || {} - return getProviderFields(provider).some(field => { - if (!field.required) return false - return !(values[field.key] || '').trim() - }) - } - - const handleSaveProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - if (!cfg.settings_put) return - setSaving(s => ({ ...s, [provider.id]: true })) - try { - await apiClient.put(cfg.settings_put, buildProviderPayload(provider)) - await refreshProviderConnection(provider) - toast.success(t('memories.saved', { provider_name: provider.name })) - } catch { - toast.error(t('memories.saveError', { provider_name: provider.name })) - } finally { - setSaving(s => ({ ...s, [provider.id]: false })) - } - } - - const handleTestProvider = async (provider: PhotoProviderAddon) => { - const cfg = getProviderConfig(provider) - const testPath = cfg.test_post || cfg.test_get || cfg.status_get - if (!testPath) return - setProviderTesting(prev => ({ ...prev, [provider.id]: true })) - try { - const payload = buildProviderPayload(provider) - const res = cfg.test_post ? await apiClient.post(testPath, payload) : await apiClient.get(testPath) - const ok = !!res.data?.connected - setProviderConnected(prev => ({ ...prev, [provider.id]: ok })) - if (ok) { - toast.success(t('memories.connectionSuccess', { provider_name: provider.name })) - } else { - const detail = res.data?.error ? `: ${String(res.data.error)}` : '' - toast.error(`${t('memories.connectionError', { provider_name: provider.name })}${detail}`) - } - } catch { - toast.error(t('memories.connectionError', { provider_name: provider.name })) - } finally { - setProviderTesting(prev => ({ ...prev, [provider.id]: false })) - } - } + const { + memoriesEnabled, + activePhotoProviders, + providerValues, + providerConnected, + providerTesting, + saving, + handleProviderFieldChange, + isProviderSaveDisabled, + handleSaveProvider, + handleTestProvider, + } = usePhotoProviderConnections({ statusRouteOwnsBadge: true, testErrorGap: '' }) const renderPhotoProviderSection = (provider: PhotoProviderAddon): React.ReactElement => { const fields = getProviderFields(provider)