Skip to content
Merged
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
71 changes: 66 additions & 5 deletions docs/firebase-ui-auth/providers/email-link.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,12 @@ MaterialApp(
},
'/email-link-sign-in': (context) => EmailLinkSignInScreen(
actions: [
AuthStateChangeAction<SignedIn>((context, state) {
Navigator.pushReplacementNamed(context, '/profile');
AuthStateChangeAction((context, state) {
if (state is SignedIn ||
state is UserCreated ||
state is CredentialLinked) {
Navigator.pushReplacementNamed(context, '/profile');
}
}),
],
),
Expand All @@ -68,9 +72,32 @@ MaterialApp(

> Notes:
>
> - a user signing in with an email link for the first time emits `UserCreated` rather than `SignedIn`, and an upgraded anonymous user (`upgradeAnonymousUsers`) emits `CredentialLinked`, so handle all three.
> - see [navigation guide](../navigation.md) to learn how navigation works with Firebase UI.
> - explore [FirebaseUIActions API docs](https://pub.dev/documentation/firebase_ui_auth/latest/firebase_ui_auth/FirebaseUIAction-class.html).

## Handling the sign in link

When the link is sent, the email is stored on the device, so the link completes the sign in even if the app was killed in the meantime.

- **Link opens the app:** `EmailLinkSignInScreen` completes the sign in with the link that launched the app. Show it on startup when `isLaunchedFromSignInLink` is true:

```dart
final launchedFromSignInLink =
await emailLinkProvider.isLaunchedFromSignInLink();

MaterialApp(
initialRoute: launchedFromSignInLink ? '/email-link-sign-in' : '/login',
// ...
);
```

Disable Flutter's built-in deep linking so it does not replace `initialRoute` with the link: add `<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />` to the `<activity>` in `AndroidManifest.xml` (not the `<application>`), and set `FlutterDeepLinkingEnabled` to `false` in `Info.plist`.

- **Link opened while the app is running:** the link is handled by the email link screen. If that screen is not showing, the link is kept until it opens, so open `EmailLinkSignInScreen` yourself when a link arrives, for example from an `app_links` listener when `FirebaseAuth.instance.isSignInWithEmailLink(link)` is true and the screen is not already open.
- **Link opened on another device:** the flow emits `EmailRequired` and `EmailLinkSignInView` asks the user to confirm their email before signing in.
- **Anonymous users:** by default an anonymous user is replaced by the signed in user. Pass `upgradeAnonymousUsers: true` to `EmailLinkAuthProvider` to link the email to the anonymous user instead: the flow then emits `CredentialLinked` instead of `SignedIn`, the link must be opened on the same device (otherwise sign in fails with an `email-link-wrong-device` error), and an email that already has an account cannot be used.

## Using view

If the pre-built screen don't suit the app's needs, you could use a `EmailLinkSignInView` to build your custom screen:
Expand All @@ -88,8 +115,12 @@ class MyEmailLinkSignInScreen extends StatelessWidget {
padding: const EdgeInsets.all(16),
child: FirebaseUIActions(
actions: [
AuthStateChangeAction<SignedIn>((context, state) {
Navigator.pushReplacementNamed(context, '/profile');
AuthStateChangeAction((context, state) {
if (state is SignedIn ||
state is UserCreated ||
state is CredentialLinked) {
Navigator.pushReplacementNamed(context, '/profile');
}
}
],
child: EmailLinkSignInView(provider: emailLinkAuthProvider),
Expand All @@ -114,7 +145,9 @@ class MyCustomWidget extends StatelessWidget {
return AuthFlowBuilder<EmailLinkAuthController>(
provider: emailLinkProvider,
listener: (oldState, newState, ctrl) {
if (newState is SignedIn) {
if (newState is SignedIn ||
newState is UserCreated ||
newState is CredentialLinked) {
Navigator.of(context).pushReplacementNamed('/profile');
}
}
Expand All @@ -128,6 +161,14 @@ class MyCustomWidget extends StatelessWidget {
);
} else if (state is AwaitingDynamicLink) {
return CircularProgressIndicator();
} else if (state is EmailRequired) {
// The link was opened on another device.
return TextField(
decoration: InputDecoration(label: Text('Confirm your email')),
onSubmitted: (email) {
ctrl.confirmEmail(email);
},
);
} else if (state is AuthFailed) {
return ErrorText(exception: state.exception);
} else {
Expand Down Expand Up @@ -165,6 +206,13 @@ class _CustomEmailLinkSignInState extends State<CustomEmailLinkSignIn>
onSubmitted: provider.sendLink,
);

@override
void initState() {
super.initState();
// Completes the sign in if a sign in link launched the app.
provider.handleIncomingLinks();
}

@override
void onBeforeLinkSent(String email) {
setState(() {
Expand All @@ -174,11 +222,24 @@ class _CustomEmailLinkSignInState extends State<CustomEmailLinkSignIn>

@override
void onLinkSent(String email) {
provider.awaitLink(email);
setState(() {
child = Text('Check your email and click the link');
});
}

@override
void onEmailRequired(String link) {
setState(() {
child = TextField(
decoration: const InputDecoration(
labelText: 'Confirm your email',
),
onSubmitted: (email) => provider.signInWithLink(email, link),
);
});
}

@override
Widget build(BuildContext context) {
return Center(child: child);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true">
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<meta-data android:name="com.facebook.sdk.ApplicationId" android:value="@string/facebook_app_id"/>
<meta-data android:name="com.facebook.sdk.ClientToken" android:value="@string/facebook_client_token"/>

Expand All @@ -25,6 +24,9 @@
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<!-- Links are handled by app_links. Flutter reads this flag from the
activity, not the application. -->
<meta-data android:name="flutter_deeplinking_enabled" android:value="false" />
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
Expand Down
23 changes: 19 additions & 4 deletions packages/firebase_ui_auth/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ Future<void> main() async {
),
]);

runApp(const FirebaseAuthUIExample());
final launchedFromSignInLink = await emailLinkProviderConfig
.isLaunchedFromSignInLink();

runApp(FirebaseAuthUIExample(launchedFromSignInLink: launchedFromSignInLink));
}

// Overrides a label for en locale
Expand All @@ -65,11 +68,19 @@ class LabelOverrides extends DefaultLocalizations {
}

class FirebaseAuthUIExample extends StatelessWidget {
const FirebaseAuthUIExample({super.key});
const FirebaseAuthUIExample({super.key, this.launchedFromSignInLink = false});

/// Whether an email sign in link launched the app, so it can complete the
/// sign in on the email link screen.
final bool launchedFromSignInLink;

String get initialRoute {
final user = FirebaseAuth.instance.currentUser;

if (launchedFromSignInLink && (user == null || user.isAnonymous)) {
return '/email-link-sign-in';
}

return switch (user) {
null => '/',
User(emailVerified: false, email: final String _) => '/verify-email',
Expand Down Expand Up @@ -260,8 +271,12 @@ class FirebaseAuthUIExample extends StatelessWidget {
'/email-link-sign-in': (context) {
return EmailLinkSignInScreen(
actions: [
AuthStateChangeAction<SignedIn>((context, state) {
Navigator.pushReplacementNamed(context, '/profile');
AuthStateChangeAction((context, state) {
if (state is SignedIn ||
state is UserCreated ||
state is CredentialLinked) {
Navigator.pushReplacementNamed(context, '/profile');
}
}),
],
provider: emailLinkProviderConfig,
Expand Down
45 changes: 45 additions & 0 deletions packages/firebase_ui_auth/lib/src/flows/email_link_flow.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// BSD-style license that can be found in the LICENSE file.

import 'package:flutter/widgets.dart';
import 'package:meta/meta.dart';
import 'package:firebase_ui_auth/firebase_ui_auth.dart';

/// A state that indicates that the sign in link is being sent.
Expand All @@ -19,10 +20,24 @@ class AwaitingDynamicLink extends AuthState {
const AwaitingDynamicLink();
}

/// A state that indicates that a sign in link was opened on a device that did
/// not request it. The user should confirm their email to complete the sign
/// in, see [EmailLinkAuthController.confirmEmail].
class EmailRequired extends AuthState {
/// The sign in link that was opened.
final String link;

const EmailRequired(this.link);
}

/// A controller interface of the [EmailLinkFlow].
abstract class EmailLinkAuthController extends AuthController {
/// Sends a sign in link to the [email].
void sendLink(String email);

/// Completes the sign in after [EmailRequired] with the [email] the user
/// confirmed.
void confirmEmail(String email);
}

/// {@template ui.auth.flows.email_link_flow}
Expand All @@ -40,6 +55,23 @@ class EmailLinkFlow extends AuthFlow<EmailLinkAuthProvider>
required super.provider,
}) : super(action: AuthAction.signIn, initialState: const Uninitialized());

/// Whether a widget, such as [AuthFlowBuilder], is listening to this flow.
@internal
bool get hasWidgetListeners => hasListeners;

@override
void addListener(VoidCallback listener) {
final wasShowing = hasListeners;
super.addListener(listener);

// The first widget to show this flow, including one that reuses it with a
// flowKey, makes it the provider's listener and receives any sign in link
// that arrived while no email link screen was showing.
if (!wasShowing) provider.handleIncomingLinks();
}

String? _pendingLink;

@override
void sendLink(String email) {
provider.sendLink(email);
Expand All @@ -55,6 +87,19 @@ class EmailLinkFlow extends AuthFlow<EmailLinkAuthProvider>
value = const AwaitingDynamicLink();
provider.awaitLink(email);
}

@override
void onEmailRequired(String link) {
_pendingLink = link;
value = EmailRequired(link);
}

@override
void confirmEmail(String email) {
final link = _pendingLink;
if (link == null) return;
provider.signInWithLink(email, link);
}
}

/// {@template ui.auth.flows.email_link_flow.email_link_sign_in_action}
Expand Down
Loading
Loading