feat: sign in with Google (#2579)

Closes #2515
This commit is contained in:
Ken-ichi
2024-12-19 12:24:16 -05:00
committed by GitHub
parent 1d340eb558
commit ebba764906
12 changed files with 248 additions and 85 deletions

View File

@@ -36,3 +36,7 @@ IOS_PROVISIONING_PROFILE_NAME="provisioning profile name"
IOS_SHARE_BUNDLE_ID="share bundle ID"
IOS_SHARE_PROVISIONING_PROFILE_NAME="share provisioning profile name"
SLACK_URL="Slack webhook URL"
# Third-party Sign in
GOOGLE_WEB_CLIENT_ID=your-google-web-client-id
GOOGLE_IOS_CLIENT_ID=your-google-ios-client-id

View File

@@ -1,4 +1,10 @@
PODS:
- AppAuth (1.7.6):
- AppAuth/Core (= 1.7.6)
- AppAuth/ExternalUserAgent (= 1.7.6)
- AppAuth/Core (1.7.6)
- AppAuth/ExternalUserAgent (1.7.6):
- AppAuth/Core
- boost (1.83.0)
- BVLinearGradient (2.8.3):
- React-Core
@@ -21,6 +27,14 @@ PODS:
- ReactCommon/turbomodule/core (= 0.73.7)
- fmt (6.2.1)
- glog (0.3.5)
- GoogleSignIn (7.1.0):
- AppAuth (< 2.0, >= 1.7.3)
- GTMAppAuth (< 5.0, >= 4.1.1)
- GTMSessionFetcher/Core (~> 3.3)
- GTMAppAuth (4.1.1):
- AppAuth/Core (~> 1.7)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- hermes-engine (0.73.7):
- hermes-engine/Pre-built (= 0.73.7)
- hermes-engine/Pre-built (0.73.7)
@@ -1151,6 +1165,9 @@ PODS:
- glog
- RCT-Folly (= 2022.05.16.00)
- React-Core
- RNGoogleSignin (13.1.0):
- GoogleSignIn (~> 7.1)
- React-Core
- RNLocalize (3.1.0):
- React-Core
- RNPermissions (4.1.5):
@@ -1276,6 +1293,7 @@ DEPENDENCIES:
- "RNFlashList (from `../node_modules/@shopify/flash-list`)"
- RNFS (from `../node_modules/react-native-fs`)
- RNGestureHandler (from `../node_modules/react-native-gesture-handler`)
- "RNGoogleSignin (from `../node_modules/@react-native-google-signin/google-signin`)"
- RNLocalize (from `../node_modules/react-native-localize`)
- RNPermissions (from `../node_modules/react-native-permissions`)
- RNReanimated (from `../node_modules/react-native-reanimated`)
@@ -1290,7 +1308,11 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- AppAuth
- fmt
- GoogleSignIn
- GTMAppAuth
- GTMSessionFetcher
- libevent
- MMKV
- MMKVCore
@@ -1461,6 +1483,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-fs"
RNGestureHandler:
:path: "../node_modules/react-native-gesture-handler"
RNGoogleSignin:
:path: "../node_modules/@react-native-google-signin/google-signin"
RNLocalize:
:path: "../node_modules/react-native-localize"
RNPermissions:
@@ -1485,6 +1509,7 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native/ReactCommon/yoga"
SPEC CHECKSUMS:
AppAuth: d4f13a8fe0baf391b2108511793e4b479691fb73
boost: d3f49c53809116a5d38da093a8aa78bf551aed09
BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70
DoubleConversion: fea03f2699887d960129cc54bba7e52542b6f953
@@ -1493,6 +1518,9 @@ SPEC CHECKSUMS:
FBReactNativeSpec: 40b791f4a1df779e7e4aa12c000319f4f216d40a
fmt: ff9d55029c625d3757ed641535fd4a75fedc7ce9
glog: c5d68082e772fa1c511173d6b30a9de2c05a69a2
GoogleSignIn: d4281ab6cf21542b1cfaff85c191f230b399d2db
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
hermes-engine: 39589e9c297d024e90fe68f6830ff86c4e01498a
libevent: 4049cae6c81cdb3654a443be001fb9bdceff7913
MMKV: f7d1d5945c8765f97f39c3d121f353d46735d801
@@ -1571,6 +1599,7 @@ SPEC CHECKSUMS:
RNFlashList: 818cb6cff1f47cabe1acd6298c98af1a39b9b18c
RNFS: 89de7d7f4c0f6bafa05343c578f61118c8282ed8
RNGestureHandler: e262eeb792addec0705a116456f210ee1be0dcd0
RNGoogleSignin: ba93c1137f8d5cebdd39b04f493fd212ddf5ecd6
RNLocalize: 080849cb8a824d9f759b8a5ae00c8321d46dbed0
RNPermissions: f14c20f4eb7a20fff611ad9f467da7bb5872ac4f
RNReanimated: b158619f02f1384a5be9e1203b58e0e4a80407d7
@@ -1586,4 +1615,4 @@ SPEC CHECKSUMS:
PODFILE CHECKSUM: eff4b75123af5d6680139a78c055b44ad37c269b
COCOAPODS: 1.15.2
COCOAPODS: 1.14.3

View File

@@ -30,6 +30,14 @@
<string>inaturalistmobile</string>
</array>
</dict>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>com.googleusercontent.apps.796686868523-4m5ne09fa17p5me04s1qdlsnci9e8qeo</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>132</string>
@@ -55,6 +63,8 @@
<string>Add existing sounds to your observations.</string>
<key>NSCameraUsageDescription</key>
<string>iNaturalist Next uses the camera to add photos to your observations of nature.</string>
<key>NSHumanReadableCopyright</key>
<string>© iNaturalist</string>
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
<string>We do not intentionally request this permission. If you are seeing this, please take a screenshot and email it to help@inaturalist.org</string>
<key>NSLocationWhenInUseUsageDescription</key>
@@ -94,7 +104,5 @@
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>© iNaturalist</string>
</dict>
</plist>

View File

@@ -12,6 +12,7 @@ const config: Config = {
preset: "react-native",
setupFiles: [
"./node_modules/react-native-gesture-handler/jestSetup.js",
"./node_modules/@react-native-google-signin/google-signin/jest/build/jest/setup.js",
"<rootDir>/tests/jest.setup.js"
],
globalSetup: "<rootDir>/tests/jest.globalSetup.js",

20
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"@react-native-community/hooks": "^3.0.0",
"@react-native-community/netinfo": "^11.3.1",
"@react-native-community/slider": "^4.5.0",
"@react-native-google-signin/google-signin": "^13.1.0",
"@react-native-picker/picker": "^2.7.2",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/drawer": "^6.6.15",
@@ -4893,6 +4894,25 @@
"resolved": "https://registry.npmjs.org/@react-native-community/slider/-/slider-4.5.0.tgz",
"integrity": "sha512-pyUvNTvu5IfCI5abzqRfO/dd3A009RC66RXZE6t0gyOwI/j0QDlq9VZRv3rjkpuIvNTnsYj+m5BHlh0DkSYUyA=="
},
"node_modules/@react-native-google-signin/google-signin": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/@react-native-google-signin/google-signin/-/google-signin-13.1.0.tgz",
"integrity": "sha512-C2/sqb0/s0c+Dwc/mykASZsRuHxGqn7SFrCxCY9D8p8IOQO05haInhCc7lzraJshRixGva5c/4usQZ71HMYSEQ==",
"peerDependencies": {
"expo": ">=50.0.0",
"react": "*",
"react-dom": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@react-native-picker/picker": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/@react-native-picker/picker/-/picker-2.7.2.tgz",

View File

@@ -56,6 +56,7 @@
"@react-native-community/hooks": "^3.0.0",
"@react-native-community/netinfo": "^11.3.1",
"@react-native-community/slider": "^4.5.0",
"@react-native-google-signin/google-signin": "^13.1.0",
"@react-native-picker/picker": "^2.7.2",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/drawer": "^6.6.15",

View File

@@ -1,4 +1,4 @@
import { appleAuth, AppleButton, AppleError } from "@invertase/react-native-apple-authentication";
import { AppleButton } from "@invertase/react-native-apple-authentication";
import { RouteProp, useNavigation, useRoute } from "@react-navigation/native";
import classnames from "classnames";
import {
@@ -9,21 +9,16 @@ import { t } from "i18next";
import { RealmContext } from "providers/contexts.ts";
import React, { useEffect, useRef, useState } from "react";
import {
Alert,
Platform,
TextInput,
TouchableWithoutFeedback
} from "react-native";
import Realm from "realm";
import { log } from "sharedHelpers/logger";
import useKeyboardInfo from "sharedHooks/useKeyboardInfo";
import colors from "styles/tailwindColors";
import {
authenticateUser,
authenticateUserByAssertion
} from "./AuthenticationService";
import { authenticateUser } from "./AuthenticationService";
import Error from "./Error";
import { signInWithApple, signInWithGoogle } from "./loginFormHelpers";
import LoginSignUpInputField from "./LoginSignUpInputField";
const { useRealm } = RealmContext;
@@ -42,85 +37,12 @@ type ParamList = {
LoginFormParams: LoginFormParams
}
interface AppleAuthError {
code: AppleError;
}
const APPLE_BUTTON_STYLE = {
maxWidth: 500,
height: 45, // You must specify a height
marginTop: 10
};
const logger = log.extend( "LoginForm" );
function showSignInWithAppleFailed() {
Alert.alert(
t( "Sign-in-with-Apple-Failed" ),
t( "If-you-have-an-existing-account-try-sign-in-reset" )
);
}
async function signInWithApple( realm: Realm ) {
// Request sign in w/ apple. This should pop up some system UI for signing
// in
let appleAuthRequestResponse;
try {
appleAuthRequestResponse = await appleAuth.performRequest( {
requestedOperation: appleAuth.Operation.LOGIN,
// Note: it appears putting FULL_NAME first is important, see issue #293
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL]
} );
} catch ( appleAuthRequestError ) {
if ( ( appleAuthRequestError as AppleAuthError ).code === appleAuth.Error.CANCELED ) {
// The user canceled sign in, no need to log
return false;
}
logger.error( "Apple auth request failed", appleAuthRequestError );
showSignInWithAppleFailed();
return false;
}
// Check if auth was successful
const credentialState = await appleAuth.getCredentialStateForUser(
appleAuthRequestResponse.user
);
// If it was, send the identity token to iNat for verification and iNat
// auth
if ( credentialState === appleAuth.State.AUTHORIZED ) {
// Note that we're supporting an irregular assertion that is a JSON object
// w/ the actual identityToken (which is itself a JSON Web Token), and
// the user's name, which we only get from Apple the *first* time that
// grant permission, so the server cannot access it when it verifies the
// token
const assertion = JSON.stringify( {
id_token: appleAuthRequestResponse.identityToken,
name: t( "apple-full-name", {
namePrefix: appleAuthRequestResponse?.fullName?.namePrefix,
givenName: appleAuthRequestResponse?.fullName?.givenName,
middleName: appleAuthRequestResponse?.fullName?.middleName,
nickname: appleAuthRequestResponse?.fullName?.nickname,
familyName: appleAuthRequestResponse?.fullName?.familyName,
nameSuffix: appleAuthRequestResponse?.fullName?.nameSuffix
} )
} );
try {
await authenticateUserByAssertion( "apple", assertion, realm );
} catch ( authenticateUserByAssertionError ) {
logger.error( "Assertion with Apple token failed", authenticateUserByAssertionError );
showSignInWithAppleFailed();
return false;
}
return true;
}
// We only get here if the user does not grant access... I think, so no need
// to log an error
logger.info( "Apple auth failed, credentialState: ", credentialState );
showSignInWithAppleFailed();
return false;
}
const LoginForm = ( {
hideFooter
}: Props ) => {
@@ -276,6 +198,12 @@ const LoginForm = ( {
onPress={() => logIn( async ( ) => signInWithApple( realm ) )}
/>
) }
<Button
text={t( "Sign-in-with-Google" )}
onPress={() => logIn( async ( ) => signInWithGoogle( realm ) )}
disabled={loading}
className="mt-3"
/>
{!hideFooter && (
<Body1
className={classnames(

View File

@@ -0,0 +1,158 @@
// Helpers for LoginForm. Might be better in AuthenticationService, but
// there's also some UI-related stuff in here, e.g. alerts
import { appleAuth, AppleError } from "@invertase/react-native-apple-authentication";
import {
GoogleSignin,
statusCodes as googleStatusCodes
} from "@react-native-google-signin/google-signin";
import { t } from "i18next";
import { Alert } from "react-native";
import Config from "react-native-config";
import Realm from "realm";
import { log } from "sharedHelpers/logger";
import {
authenticateUserByAssertion
} from "./AuthenticationService";
interface AppleAuthError {
code: AppleError;
}
const logger = log.extend( "loginFormHelpers" );
function showSignInWithAppleFailed() {
Alert.alert(
t( "Sign-in-with-Apple-Failed" ),
t( "If-you-have-an-existing-account-try-sign-in-reset" )
);
}
async function signInWithApple( realm: Realm ) {
// Request sign in w/ apple. This should pop up some system UI for signing
// in
let appleAuthRequestResponse;
try {
appleAuthRequestResponse = await appleAuth.performRequest( {
requestedOperation: appleAuth.Operation.LOGIN,
// Note: it appears putting FULL_NAME first is important, see issue #293
requestedScopes: [appleAuth.Scope.FULL_NAME, appleAuth.Scope.EMAIL]
} );
} catch ( appleAuthRequestError ) {
if ( ( appleAuthRequestError as AppleAuthError ).code === appleAuth.Error.CANCELED ) {
// The user canceled sign in, no need to log
return false;
}
logger.error( "Apple auth request failed", appleAuthRequestError );
showSignInWithAppleFailed();
return false;
}
// Check if auth was successful
const credentialState = await appleAuth.getCredentialStateForUser(
appleAuthRequestResponse.user
);
// If it was, send the identity token to iNat for verification and iNat
// auth
if ( credentialState === appleAuth.State.AUTHORIZED ) {
// Note that we're supporting an irregular assertion that is a JSON object
// w/ the actual identityToken (which is itself a JSON Web Token), and
// the user's name, which we only get from Apple the *first* time that
// grant permission, so the server cannot access it when it verifies the
// token
const assertion = JSON.stringify( {
id_token: appleAuthRequestResponse.identityToken,
name: t( "apple-full-name", {
namePrefix: appleAuthRequestResponse?.fullName?.namePrefix,
givenName: appleAuthRequestResponse?.fullName?.givenName,
middleName: appleAuthRequestResponse?.fullName?.middleName,
nickname: appleAuthRequestResponse?.fullName?.nickname,
familyName: appleAuthRequestResponse?.fullName?.familyName,
nameSuffix: appleAuthRequestResponse?.fullName?.nameSuffix
} )
} );
try {
await authenticateUserByAssertion( "apple", assertion, realm );
} catch ( authenticateUserByAssertionError ) {
logger.error( "Assertion with Apple token failed", authenticateUserByAssertionError );
showSignInWithAppleFailed();
return false;
}
return true;
}
// We only get here if the user does not grant access... I think, so no need
// to log an error
logger.info( "Apple auth failed, credentialState: ", credentialState );
showSignInWithAppleFailed();
return false;
}
GoogleSignin.configure( {
iosClientId: Config.GOOGLE_IOS_CLIENT_ID,
webClientId: Config.GOOGLE_WEB_CLIENT_ID,
scopes: [
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile"
]
} );
async function confirmGooglePlayServices() {
try {
await GoogleSignin.hasPlayServices( );
return true;
} catch ( hasPlayServicesError ) {
if (
( hasPlayServicesError as { code: string } ).code
=== googleStatusCodes.PLAY_SERVICES_NOT_AVAILABLE
) {
Alert.alert(
t( "Google-Play-Services-Not-Installed" ),
t( "You-must-install-Google-Play-Services-to-sign-in-with-Google" )
);
return false;
}
throw hasPlayServicesError;
}
}
function showSignInWithGoogleFailed() {
Alert.alert(
t( "Sign-in-with-Google-Failed" ),
t( "If-you-have-an-existing-account-try-sign-in-reset" )
);
}
async function signInWithGoogle( realm: Realm ) {
const hasPlayServices = await confirmGooglePlayServices( );
if ( !hasPlayServices ) return false;
let signInResp;
try {
signInResp = await GoogleSignin.signIn();
} catch ( signInError ) {
logger.error( "Failed to sign in with Google", signInError );
return false;
}
if ( signInResp.type === "cancelled" ) return false;
let tokens;
try {
tokens = await GoogleSignin.getTokens();
} catch ( getTokensError ) {
logger.error( "Failed to get tokens from Google", getTokensError );
return false;
}
if ( !tokens?.accessToken ) return false;
try {
await authenticateUserByAssertion( "google", tokens.accessToken, realm );
} catch ( authenticateUserByAssertionError ) {
logger.error( "Assertion with Google token failed", authenticateUserByAssertionError );
showSignInWithGoogleFailed();
return false;
}
return true;
}
export {
signInWithApple,
signInWithGoogle
};

View File

@@ -22,7 +22,7 @@ interface ButtonProps {
testID?: string;
text: string;
dropdown?: boolean;
maxFontSizeMultiplier: number
maxFontSizeMultiplier?: number
}
const setStyles = ( {

View File

@@ -513,6 +513,7 @@ Get-more-accurate-suggestions-create-useful-data = Get more accurate suggestions
Get-your-identification-verified-by-real-people = Get your identification verified by real people in the iNaturalist community
# Label for button that returns to the previous screen
Go-back = Go back
Google-Play-Services-Not-Installed = Google Play Services Not Installed
# Text for a button that asks the user to grant permission
GRANT-PERMISSION = GRANT PERMISSION
# Title of a screen asking for permission
@@ -1110,6 +1111,9 @@ Shows-iNaturalist-bird-logo = Shows iNaturalist bird logo.
Shows-observation-creation-options = Shows observation creation options
# Title of an error alert when Sign in with Apple fails
Sign-in-with-Apple-Failed = Sign in with Apple Failed
# Label for button that allows user to sign in with their Google account
Sign-in-with-Google = Sign in with Google
Sign-in-with-Google-Failed = Sign in with Google Failed
Some-data-privacy-laws = Some data privacy laws, like the European Union's General Data Protection Regulation (GDPR), require explicit consent to transfer personal information from their jurisdictions to other jurisdictions where the legal protection of this information is not considered adequate. As of 2020, the European Union no longer considers the United States to be a jurisdiction that provides adequate legal protection of personal information, specifically because of the possibility of the US government surveilling data entering the US. It is possible other jurisdictions may have the same opinion.
# Generic error message
Something-went-wrong = Something went wrong.
@@ -1454,6 +1458,7 @@ You-changed-filters-will-be-discarded = You changed filters, but they were not a
You-have-opted-out-of-the-Community-Taxon = You have opted out of the Community Taxon
You-havent-joined-any-projects-yet = You havent joined any projects yet!
You-must-be-logged-in-to-view-messages = You must be logged in to view messages
You-must-install-Google-Play-Services-to-sign-in-with-Google = You must install Google Play Services to sign in with Google.
# Error message when you try to do something that requires an Internet
# connection but such a connection is, tragically, missing
You-need-an-Internet-connection-to-do-that = You need an Internet connection to do that.

View File

@@ -292,6 +292,7 @@
"Get-more-accurate-suggestions-create-useful-data": "Get more accurate suggestions & create useful data for science using your location",
"Get-your-identification-verified-by-real-people": "Get your identification verified by real people in the iNaturalist community",
"Go-back": "Go back",
"Google-Play-Services-Not-Installed": "Google Play Services Not Installed",
"GRANT-PERMISSION": "GRANT PERMISSION",
"Grant-Permission-title": "Grant Permission",
"Grid-layout": "Grid layout",
@@ -714,6 +715,8 @@
"Shows-iNaturalist-bird-logo": "Shows iNaturalist bird logo.",
"Shows-observation-creation-options": "Shows observation creation options",
"Sign-in-with-Apple-Failed": "Sign in with Apple Failed",
"Sign-in-with-Google": "Sign in with Google",
"Sign-in-with-Google-Failed": "Sign in with Google Failed",
"Some-data-privacy-laws": "Some data privacy laws, like the European Union's General Data Protection Regulation (GDPR), require explicit consent to transfer personal information from their jurisdictions to other jurisdictions where the legal protection of this information is not considered adequate. As of 2020, the European Union no longer considers the United States to be a jurisdiction that provides adequate legal protection of personal information, specifically because of the possibility of the US government surveilling data entering the US. It is possible other jurisdictions may have the same opinion.",
"Something-went-wrong": "Something went wrong.",
"Sorry-this-observation-was-deleted": "Sorry, this observation was deleted",
@@ -895,6 +898,7 @@
"You-have-opted-out-of-the-Community-Taxon": "You have opted out of the Community Taxon",
"You-havent-joined-any-projects-yet": "You havent joined any projects yet!",
"You-must-be-logged-in-to-view-messages": "You must be logged in to view messages",
"You-must-install-Google-Play-Services-to-sign-in-with-Google": "You must install Google Play Services to sign in with Google.",
"You-need-an-Internet-connection-to-do-that": "You need an Internet connection to do that.",
"You-need-log-in-to-do-that": "You need to log in to do that.",
"You-will-see-notifications": "Youll see notifications here once you log in & upload observations.",

View File

@@ -513,6 +513,7 @@ Get-more-accurate-suggestions-create-useful-data = Get more accurate suggestions
Get-your-identification-verified-by-real-people = Get your identification verified by real people in the iNaturalist community
# Label for button that returns to the previous screen
Go-back = Go back
Google-Play-Services-Not-Installed = Google Play Services Not Installed
# Text for a button that asks the user to grant permission
GRANT-PERMISSION = GRANT PERMISSION
# Title of a screen asking for permission
@@ -1110,6 +1111,9 @@ Shows-iNaturalist-bird-logo = Shows iNaturalist bird logo.
Shows-observation-creation-options = Shows observation creation options
# Title of an error alert when Sign in with Apple fails
Sign-in-with-Apple-Failed = Sign in with Apple Failed
# Label for button that allows user to sign in with their Google account
Sign-in-with-Google = Sign in with Google
Sign-in-with-Google-Failed = Sign in with Google Failed
Some-data-privacy-laws = Some data privacy laws, like the European Union's General Data Protection Regulation (GDPR), require explicit consent to transfer personal information from their jurisdictions to other jurisdictions where the legal protection of this information is not considered adequate. As of 2020, the European Union no longer considers the United States to be a jurisdiction that provides adequate legal protection of personal information, specifically because of the possibility of the US government surveilling data entering the US. It is possible other jurisdictions may have the same opinion.
# Generic error message
Something-went-wrong = Something went wrong.
@@ -1454,6 +1458,7 @@ You-changed-filters-will-be-discarded = You changed filters, but they were not a
You-have-opted-out-of-the-Community-Taxon = You have opted out of the Community Taxon
You-havent-joined-any-projects-yet = You havent joined any projects yet!
You-must-be-logged-in-to-view-messages = You must be logged in to view messages
You-must-install-Google-Play-Services-to-sign-in-with-Google = You must install Google Play Services to sign in with Google.
# Error message when you try to do something that requires an Internet
# connection but such a connection is, tragically, missing
You-need-an-Internet-connection-to-do-that = You need an Internet connection to do that.