Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions SparkyFitnessFrontend/src/api/Settings/externalProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,21 @@ export const handleManualSyncStrava = async (
}
};

export const handleManualSyncMFP = async (
startDate?: string,
endDate?: string
) => {
try {
await apiCall(`/integrations/myfitnesspal/sync`, {
method: 'POST',
body: JSON.stringify({ startDate, endDate }),
});
} catch (error: unknown) {
console.error('Error initiating manual MFP sync:', error);
throw error;
}
};

export const fetchBaseProviders = async (): Promise<ExternalDataProvider[]> => {
return apiCall('/external-providers', {
method: 'GET',
Expand All @@ -325,8 +340,10 @@ export const fetchGarminStatus = async (): Promise<GarminStatusResponse> => {
};

export interface OAuthStatusResponse {
lastSyncAt: string;
tokenExpiresAt: string;
lastSyncAt?: string;
tokenExpiresAt?: string;
isLinked?: boolean;
lastUpdated?: string;
}

export const fetchWithingsStatus = async (
Expand Down Expand Up @@ -364,6 +381,10 @@ export const fetchStravaStatus = async (): Promise<OAuthStatusResponse> => {
return apiCall('/integrations/strava/status');
};

export const fetchMFPStatus = async (): Promise<OAuthStatusResponse> => {
return apiCall('/integrations/myfitnesspal/status');
};

export const getEnrichedProviders = async (): Promise<
ExternalDataProvider[]
> => {
Expand Down Expand Up @@ -429,6 +450,12 @@ export const getEnrichedProviders = async (): Promise<
}
break;
}
case 'myfitnesspal': {
const status = await fetchMFPStatus();
enriched.has_token = status.isLinked;
enriched.last_sync_at = status.lastUpdated; // Standard field
break;
}
}
} catch (error) {
console.error(
Expand Down
14 changes: 14 additions & 0 deletions SparkyFitnessFrontend/src/hooks/Integrations/useIntegrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
fetchGarminStatus,
GarminMfaPayload,
resumeGarminLogin,
handleManualSyncMFP,
} from '@/api/Settings/externalProviderService';
import { garminKeys } from '@/api/keys/integrations';
import { externalProviderKeys } from '@/api/keys/settings';
Expand Down Expand Up @@ -288,6 +289,19 @@ export const useManualSyncStravaMutation = () => {
},
});
};

export const useManualSyncMFPMutation = () => {
const invalidateSyncData = useDiaryInvalidation();

return useMutation({
mutationFn: ({ startDate, endDate }: SyncVariables) =>
handleManualSyncMFP(startDate, endDate),
onSuccess: () => {
invalidateSyncData();
},
});
};

export interface GarminStatusResponse {
isLinked: boolean;
lastUpdated: string | null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,18 @@ const AddExternalProviderForm = ({
});
}}
/>
{newProvider.provider_type === 'myfitnesspal' && (
<div
className="p-3 mb-4 text-sm text-red-800 rounded-lg bg-red-50 dark:bg-gray-800 dark:text-red-400 border border-red-200"
role="alert"
>
<span className="font-bold">⚠️ Strong Warning:</span> MyFitnessPal
synchronization will{' '}
<span className="underline">overwrite all manual entries</span> in
your MFP diary for the synced day. This ensures idempotency and
keeps SparkyFitness as the source of truth.
</div>
)}
<div className="flex items-center space-x-2">
<Switch
id="new_is_active"
Expand All @@ -295,6 +307,38 @@ const AddExternalProviderForm = ({
<Label htmlFor="new_is_active">Activate this provider</Label>
</div>

{[
'withings',
'garmin',
'fitbit',
'strava',
'polar',
'hevy',
'myfitnesspal',
].includes(newProvider.provider_type || '') && (
<div className="space-y-2">
<Label htmlFor="new_sync_frequency">Sync Frequency</Label>
<Select
value={newProvider.sync_frequency || 'manual'}
onValueChange={(value) =>
setNewProvider((prev) => ({
...prev,
sync_frequency: value as 'hourly' | 'daily' | 'manual',
}))
}
>
<SelectTrigger id="new_sync_frequency">
<SelectValue placeholder="Select sync frequency" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">Manual</SelectItem>
<SelectItem value="hourly">Hourly</SelectItem>
<SelectItem value="daily">Daily</SelectItem>
</SelectContent>
</Select>
</div>
)}

<div className="flex gap-2">
<Button disabled={isAnyIntegrationPending} type="submit">
<Save className="h-4 w-4 mr-2" />
Expand Down
61 changes: 58 additions & 3 deletions SparkyFitnessFrontend/src/pages/Settings/EditProviderForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -490,12 +490,66 @@ export const EditProviderForm = ({
<Clipboard className="h-4 w-4" />
</Button>
</strong>
and ensuring your local URL is correct if testing locally. Note:
Strava callback URL on the server is configured to:
<strong>{`${window.location.origin}/strava/callback`}</strong>
</p>
</>
)}
{editData.provider_type === 'myfitnesspal' && (
<>
{/* Show connection status for connected MyFitnessPal accounts instead of credential fields */}
{provider.app_id && provider.app_key ? (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="flex items-center gap-2 text-green-700 dark:text-green-400">
<div className="h-2 w-2 bg-green-500 rounded-full"></div>
<span className="font-medium">Connected to MyFitnessPal</span>
</div>
<p className="text-sm text-muted-foreground mt-2">
Your MyFitnessPal account is connected. To reconnect with
different credentials, disconnect first and add a new provider.
</p>
</div>
) : (
<>
<div>
<Label>MFP CSRF Token (x-csrf-token)</Label>
<Input
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The input field for the MyFitnessPal CSRF token is using type="text", which leaves sensitive session-level credentials visible on the screen. It is recommended to use type="password" to prevent shoulder surfing and improve security.

Suggested change
<Input
type="password"

type="text"
value={editData.app_id || ''}
onChange={(e) =>
setEditData((prev) => ({
...prev,
app_id: e.target.value,
}))
}
placeholder="Paste x-csrf-token from Network tab"
autoComplete="off"
/>
</div>
<div>
<Label>MFP Session Cookies</Label>
<Input
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The input field for MyFitnessPal session cookies is using type="text". Since these cookies grant full access to the user's account, they should be treated as sensitive credentials and masked using type="password".

Suggested change
<Input
type="password"

type="text"
value={editData.app_key || ''}
onChange={(e) =>
setEditData((prev) => ({
...prev,
app_key: e.target.value,
}))
}
placeholder="Paste full Cookie string from Network tab"
autoComplete="off"
/>
</div>
<p className="text-sm text-muted-foreground col-span-2">
<strong>How to find:</strong> Open MyFitnessPal in browser,
press F12 (Network tab), find a request to{' '}
<code>www.myfitnesspal.com</code>, and copy <code>Cookie</code>{' '}
and <code>x-csrf-token</code> from Request Headers.
</p>
</>
)}
</>
)}
{editData.provider_type === 'hevy' && (
<>
<div>
Expand Down Expand Up @@ -523,7 +577,8 @@ export const EditProviderForm = ({
editData.provider_type === 'fitbit' ||
editData.provider_type === 'strava' ||
editData.provider_type === 'polar' ||
editData.provider_type === 'hevy') && (
editData.provider_type === 'hevy' ||
editData.provider_type === 'myfitnesspal') && (
<div>
<Label htmlFor="edit_sync_frequency">Sync Frequency</Label>
<Select
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ export interface ExternalDataProvider {
| 'fitbit'
| 'polar'
| 'hevy'
| 'myfitnesspal'
| 'strava';
app_id: string | null;
app_key: string | null;
Expand Down
28 changes: 25 additions & 3 deletions SparkyFitnessFrontend/src/pages/Settings/ProviderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
useManualSyncGarminMutation,
useManualSyncPolarMutation,
useManualSyncStravaMutation,
useManualSyncMFPMutation,
useSyncHevyMutation,
} from '@/hooks/Integrations/useIntegrations';
import {
Expand Down Expand Up @@ -95,6 +96,8 @@ export const ProviderCard = ({
useManualSyncPolarMutation();
const { mutate: handleManualSyncStrava, isPending: isSyncStravaPending } =
useManualSyncStravaMutation();
const { mutate: handleManualSyncMFP, isPending: isSyncMFPPending } =
useManualSyncMFPMutation();
const { mutate: syncHevyData, isPending: isSyncHevyPending } =
useSyncHevyMutation();

Expand Down Expand Up @@ -133,6 +136,9 @@ export const ProviderCard = ({
endDate,
});
break;
case 'myfitnesspal':
handleManualSyncMFP({ startDate, endDate });
break;
}
};

Expand All @@ -155,6 +161,7 @@ export const ProviderCard = ({
isSyncPolarPending ||
isSyncStravaPending ||
isSyncHevyPending ||
isSyncMFPPending ||
isToggleSharingPending;

const handleToggleActive = async (providerId: string, isActive: boolean) => {
Expand Down Expand Up @@ -270,6 +277,15 @@ export const ProviderCard = ({
tokenExpires: null,
hasToken: isLinked && provider.is_active,
};
case 'myfitnesspal':
return {
connect: null,
disconnect: null,
sync: () => setIsSyncDialogOpen(true),
lastSync: provider.last_sync_at,
tokenExpires: null,
hasToken: (provider.app_id || provider.app_key) && provider.is_active,
};
default:
return null;
}
Expand Down Expand Up @@ -402,9 +418,15 @@ export const ProviderCard = ({
)}
</div>

{['fitbit', 'withings', 'polar', 'garmin', 'hevy', 'strava'].includes(
provider.provider_type
) && (
{[
'fitbit',
'withings',
'polar',
'garmin',
'hevy',
'strava',
'myfitnesspal',
].includes(provider.provider_type) && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-2 text-xs text-yellow-800 dark:text-yellow-200 mt-2 flex items-center gap-1">
<strong>Note from CodewithCJ:</strong> I don't own{' '}
{provider.provider_name} device/subscription.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -28,6 +29,7 @@ export const ProviderSpecificFields = ({
const needsAppId = [
'nutritionix',
'fatsecret',
'edamam',
'withings',
'fitbit',
'strava',
Expand All @@ -38,6 +40,7 @@ export const ProviderSpecificFields = ({
'tandoor',
'nutritionix',
'fatsecret',
'edamam',
'usda',
'withings',
'fitbit',
Expand Down Expand Up @@ -146,6 +149,43 @@ export const ProviderSpecificFields = ({
</>
)}

{provider.provider_type === 'myfitnesspal' && (
<>
<div>
<Label htmlFor="add-mfp-csrf">MFP CSRF Token (x-csrf-token)</Label>
<Input
id="add-mfp-csrf"
type="text"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Sensitive credentials like the MFP CSRF token should not be displayed in plain text. Please change the input type to password.

Suggested change
type="text"
type="password"

value={provider.app_id || ''}
onChange={(e) =>
setProvider((prev) => ({ ...prev, app_id: e.target.value }))
}
placeholder="Paste x-csrf-token from Network tab"
autoComplete="off"
/>
</div>
<div>
<Label htmlFor="add-mfp-cookies">MFP Session Cookies</Label>
<Input
id="add-mfp-cookies"
type="text"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

Session cookies are highly sensitive. Using type="password" ensures they are masked in the UI during the provider setup process.

Suggested change
type="text"
type="password"

value={provider.app_key || ''}
onChange={(e) =>
setProvider((prev) => ({ ...prev, app_key: e.target.value }))
}
placeholder="Paste full Cookie string from Network tab"
autoComplete="off"
/>
</div>
<p className="text-sm text-muted-foreground col-span-2">
<strong>How to find:</strong> Open MyFitnessPal in browser, press
F12 (Network tab), find a request to{' '}
<code>www.myfitnesspal.com</code>, and copy <code>Cookie</code> and{' '}
<code>x-csrf-token</code> from Request Headers.
</p>
</>
)}

{['withings', 'fitbit', 'strava', 'polar'].includes(
provider.provider_type || ''
) && (
Expand Down
3 changes: 3 additions & 0 deletions SparkyFitnessFrontend/src/utils/languageUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const getSupportedLanguages = (): string[] => {
'sl',
'sv',
'ta',
'uk',
];
};

Expand Down Expand Up @@ -44,6 +45,8 @@ export const getLanguageDisplayName = (langCode: string): string => {
return 'Svenska';
case 'ta':
return 'தமிழ்';
case 'uk':
return 'Українська';
default:
return langCode;
}
Expand Down
Loading