Complete verification links and prefill login with the verified email #61
No files matched your search
@@ -179,10 +179,11 @@ func (s *Service) sendActivation(ctx context.Context, a *customer.Account, key s
|
|||||||
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
||||||
return result.ExpiresAt, err
|
return result.ExpiresAt, err
|
||||||
}
|
}
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
|
||||||
if len(raw) != 43 {
|
if len(raw) != 43 {
|
||||||
return verification.ErorrTokenNotFound()
|
return "", verification.ErorrTokenNotFound()
|
||||||
}
|
}
|
||||||
|
var email string
|
||||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
email = a.Login
|
||||||
|
return nil, nil
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
}
|
}
|
||||||
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
|||||||
if err := decodeFlow(r, &req); err != nil {
|
if err := decodeFlow(r, &req); err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
email, err := pr.flow.Activate(r.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Account activation completed")
|
pr.logger.Info("Account activation completed")
|
||||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
return response.Accepted(pr.logger, struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{Verified: true, Email: email})
|
||||||
}
|
}
|
||||||
func challengeScope(raw string) string {
|
func challengeScope(raw string) string {
|
||||||
h := sha256.Sum256([]byte(raw))
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
|||||||
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
|
||||||
|
require.Equal(t, 400, invalid.Code)
|
||||||
|
require.NotContains(t, invalid.Body.String(), a.Login)
|
||||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||||
service.Facts = guard
|
service.Facts = guard
|
||||||
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
blocked := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 503, blocked.Code)
|
||||||
|
require.NotContains(t, blocked.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
guard.block = false
|
guard.block = false
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||||
|
var confirmed struct {
|
||||||
|
Data struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
|
||||||
|
require.True(t, confirmed.Data.Verified)
|
||||||
|
require.Equal(t, a.Login, confirmed.Data.Email)
|
||||||
|
used := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 400, used.Code)
|
||||||
|
require.NotContains(t, used.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, customer.AccountActive, a.Status)
|
require.Equal(t, customer.AccountActive, a.Status)
|
||||||
|
|||||||
@@ -214,3 +214,45 @@ This verifies the browser metadata, not Safari's credential popup. With Flutter
|
|||||||
web accessibility semantics enabled, unfocused semantic inputs initially use
|
web accessibility semantics enabled, unfocused semantic inputs initially use
|
||||||
framework-default autocomplete values until focused; password-manager behavior
|
framework-default autocomplete values until focused; password-manager behavior
|
||||||
with assistive technology still requires a native browser audit.
|
with assistive technology still requires a native browser audit.
|
||||||
|
|
||||||
|
## Verification links and login handoff
|
||||||
|
|
||||||
|
Task: [#60](https://git.sendico.io/tech/sxchnge/issues/60), branch
|
||||||
|
`email-verification-60`. Email links use `/verify-email?token=…`; the web entry
|
||||||
|
point installs Flutter's path URL strategy before initializing the application.
|
||||||
|
This matches Caddy's existing SPA fallback and preserves the path and query on a
|
||||||
|
fresh page load. Previously the default hash strategy could start at login and
|
||||||
|
never submit the activation token. Unknown routes retain the canonical login
|
||||||
|
fallback; invalid, expired and failed confirmation attempts remain on the
|
||||||
|
verification page with the existing recovery actions.
|
||||||
|
|
||||||
|
Activation consumes the token, checks the pending account and accepted signup
|
||||||
|
facts, and activates the account in the existing database transaction. Only
|
||||||
|
after a successful commit does the API return `{verified: true, email: ...}`.
|
||||||
|
Failure responses do not disclose the account email. Responses retain the
|
||||||
|
shared no-store policy. The frontend backend DTO and mapper produce a
|
||||||
|
`VerificationResult`; the verification provider exposes that result only in the
|
||||||
|
verified state and clears it on reset, loading and failure. A verified response
|
||||||
|
from an older backend without the optional email can still show success.
|
||||||
|
|
||||||
|
Success stays visible until the user chooses **Continue to login**. This replaces
|
||||||
|
the misleading workspace action that could redirect an unauthenticated visitor
|
||||||
|
back to login. The verified email travels through transient router extras into
|
||||||
|
the editable login controller; the password starts empty and receives focus.
|
||||||
|
Normal password and second-factor checks remain required. The email is not
|
||||||
|
added to URLs, serialized history, persistent storage or analytics. A link opened
|
||||||
|
in another tab or device can receive the verified address from the API without
|
||||||
|
depending on the originating signup form's memory.
|
||||||
|
|
||||||
|
Existing requested/succeeded/failed email-confirmation journey signals remain
|
||||||
|
the measurement source. Success continues to mean that the confirmation service
|
||||||
|
returned a verified result, with simulated outcomes kept separate. No field or
|
||||||
|
prefill event is needed. Existing limits around pre-submit abandonment,
|
||||||
|
cross-device journey correlation and the in-memory analytics sink still apply.
|
||||||
|
|
||||||
|
Regression coverage includes transactional activation and rollback, returned
|
||||||
|
email and failure privacy, used-token rejection, backend response mapping,
|
||||||
|
provider identity clearing, EN/RU success-to-login navigation, editable prefill,
|
||||||
|
empty focused password, and exclusion of email from serialized navigation state.
|
||||||
|
The supplied private diagnostic token is not a test fixture or documentation
|
||||||
|
input; it is kept out of repository sources and tracker records.
|
||||||
@@ -19,7 +19,8 @@ import 'validation.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthForm extends StatefulWidget {
|
class AuthForm extends StatefulWidget {
|
||||||
const AuthForm({super.key});
|
const AuthForm({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthForm> createState() => _AuthFormState();
|
State<AuthForm> createState() => _AuthFormState();
|
||||||
@@ -33,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
bool _showPassword = false;
|
bool _showPassword = false;
|
||||||
bool _showConfirmation = false;
|
bool _showConfirmation = false;
|
||||||
bool _submitted = false;
|
bool _submitted = false;
|
||||||
final _email = TextEditingController();
|
late final _email = TextEditingController(text: widget.email);
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
|
|
||||||
AuthProvider get _provider =>
|
AuthProvider get _provider =>
|
||||||
@@ -264,6 +265,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
key: const ValueKey('password'),
|
key: const ValueKey('password'),
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _password,
|
controller: _password,
|
||||||
|
autofocus: widget.email?.isNotEmpty ?? false,
|
||||||
obscureText: !_showPassword,
|
obscureText: !_showPassword,
|
||||||
autofillHints: [
|
autofillHints: [
|
||||||
_signUp
|
_signUp
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import 'form.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthPage extends StatefulWidget {
|
class AuthPage extends StatefulWidget {
|
||||||
const AuthPage({super.key});
|
const AuthPage({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthPage> createState() => _AuthPageState();
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
Center(
|
Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: AuthForm(key: _formKey),
|
child: AuthForm(key: _formKey, email: widget.email),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Send a new verification email",
|
"verificationRequest": "Send a new verification email",
|
||||||
"verificationRetry": "Retry verification",
|
"verificationRetry": "Retry verification",
|
||||||
"verificationBack": "Back to login",
|
"verificationBack": "Back to login",
|
||||||
|
"verificationContinue": "Continue to login",
|
||||||
"verificationHint": "No email? Check your spam folder or request another below.",
|
"verificationHint": "No email? Check your spam folder or request another below.",
|
||||||
"verificationDemoLink": "Continue to confirmation",
|
"verificationDemoLink": "Continue to confirmation",
|
||||||
"verificationStep": "CONFIRM YOUR EMAIL",
|
"verificationStep": "CONFIRM YOUR EMAIL",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Отправить новое письмо",
|
"verificationRequest": "Отправить новое письмо",
|
||||||
"verificationRetry": "Повторить подтверждение",
|
"verificationRetry": "Повторить подтверждение",
|
||||||
"verificationBack": "Вернуться ко входу",
|
"verificationBack": "Вернуться ко входу",
|
||||||
|
"verificationContinue": "Перейти ко входу",
|
||||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||||
"verificationDemoLink": "Перейти к подтверждению",
|
"verificationDemoLink": "Перейти к подтверждению",
|
||||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
@@ -18,6 +20,7 @@ import 'routing/router.dart';
|
|||||||
import 'generated/i18n/app_localizations.dart';
|
import 'generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
|
usePathUrlStrategy();
|
||||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||||
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: AppRoutes.login,
|
name: AppRoutes.login,
|
||||||
builder: (context, state) => const AuthPage(),
|
builder: (context, state) => AuthPage(
|
||||||
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/verification-sent',
|
path: '/verification-sent',
|
||||||
|
|||||||
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (verified) ...[
|
if (verified) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.go('/accounts'),
|
key: const ValueKey('verification-continue'),
|
||||||
child: Text(l.wEnter),
|
onPressed: () => context.goNamed(
|
||||||
|
AppRoutes.login,
|
||||||
|
extra: state.result?.verifiedEmail,
|
||||||
|
),
|
||||||
|
child: Text(l.verificationContinue),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!verified) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
|
child: Text(l.verificationBack),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
|
||||||
child: Text(l.verificationBack),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ dependencies:
|
|||||||
path: ../packages/sxchnge_analytics
|
path: ../packages/sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
sxchnge_auth:
|
sxchnge_auth:
|
||||||
path: ../packages/sxchnge_auth
|
path: ../packages/sxchnge_auth
|
||||||
sxchnge_theme:
|
sxchnge_theme:
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ void main() {
|
|||||||
find.byKey(const ValueKey('verification-verified')),
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
await tester.ensureVisible(find.text('Back to login'));
|
await tester.ensureVisible(find.text('Continue to login'));
|
||||||
await tester.tap(find.text('Back to login'));
|
await tester.tap(find.text('Continue to login'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
expect(find.text('example-password'), findsNothing);
|
expect(find.text('example-password'), findsNothing);
|
||||||
|
|||||||
@@ -42,6 +42,67 @@ Future<GoRouter> openRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'verified link keeps success visible and prefills login ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.platformDispatcher.defaultRouteNameTestValue =
|
||||||
|
'/verify-email?token=demo-valid';
|
||||||
|
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
verificationService: const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
verifiedEmail: 'private-alias@example.com',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('email')), findsNothing);
|
||||||
|
final router = GoRouter.of(
|
||||||
|
tester.element(find.byType(VerificationPage)),
|
||||||
|
);
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('verification-continue')),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('verification-continue')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final email = tester.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'private-alias@example.com');
|
||||||
|
final password = tester.state<EditableTextState>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('password')),
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(password.currentTextEditingValue.text, isEmpty);
|
||||||
|
expect(password.widget.focusNode.hasFocus, isTrue);
|
||||||
|
expect(router.routeInformationProvider.value.uri.toString(), '/');
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(
|
||||||
|
restored.state.toString(),
|
||||||
|
isNot(contains('private-alias@example.com')),
|
||||||
|
);
|
||||||
|
expect(restored.state.toString(), isNot(contains('demo-valid')));
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
'changed@example.com',
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'changed@example.com');
|
||||||
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
testWidgets('unknown deep link redirects to canonical login URL', (
|
testWidgets('unknown deep link redirects to canonical login URL', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class BackendDTO {
|
|||||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
final Map<String, dynamic> data;
|
final Map<String, dynamic> data;
|
||||||
|
bool get verified => data['verified'] == true;
|
||||||
|
String? get email => data['email'] as String?;
|
||||||
String get error => data['error'] as String? ?? 'unavailable';
|
String get error => data['error'] as String? ?? 'unavailable';
|
||||||
String get userId =>
|
String get userId =>
|
||||||
data['userId'] as String? ??
|
data['userId'] as String? ??
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class AuthDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationDTO {
|
class VerificationDTO {
|
||||||
const VerificationDTO({required this.simulated});
|
const VerificationDTO({required this.simulated, this.email});
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
|
final String? email;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
|
|||||||
import '../models/verification.dart';
|
import '../models/verification.dart';
|
||||||
|
|
||||||
abstract final class BackendMapper {
|
abstract final class BackendMapper {
|
||||||
|
static VerificationResult activation(BackendDTO dto) {
|
||||||
|
if (!dto.verified) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto) {
|
||||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
|
|||||||
|
|
||||||
abstract final class VerificationMapper {
|
abstract final class VerificationMapper {
|
||||||
static VerificationResult fromDTO(VerificationDTO dto) =>
|
static VerificationResult fromDTO(VerificationDTO dto) =>
|
||||||
VerificationResult(isSimulated: dto.simulated);
|
VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
|
||||||
}
|
}
|
||||||
@@ -17,15 +17,22 @@ class VerificationException implements Exception {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationResult {
|
class VerificationResult {
|
||||||
const VerificationResult({required this.isSimulated});
|
const VerificationResult({required this.isSimulated, this.verifiedEmail});
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final String? verifiedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerificationState {
|
class VerificationState {
|
||||||
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
const VerificationState(
|
||||||
|
this.status, {
|
||||||
|
this.isSimulated = false,
|
||||||
|
this.error,
|
||||||
|
this.result,
|
||||||
|
});
|
||||||
final VerificationStatus status;
|
final VerificationStatus status;
|
||||||
final VerificationError? error;
|
final VerificationError? error;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final VerificationResult? result;
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
status == VerificationStatus.sending ||
|
status == VerificationStatus.sending ||
|
||||||
status == VerificationStatus.checking;
|
status == VerificationStatus.checking;
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final result = await operation();
|
final result = await operation();
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
_state = VerificationState(success, isSimulated: result.isSimulated);
|
_state = VerificationState(
|
||||||
|
success,
|
||||||
|
isSimulated: result.isSimulated,
|
||||||
|
result: success == VerificationStatus.verified ? result : null,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
final code = error is VerificationException
|
final code = error is VerificationException
|
||||||
|
|||||||
@@ -172,8 +172,10 @@ class BackendAuthServices
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> confirm({required String token}) async {
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
await _post('verification/confirm', {'token': token}, verification: true);
|
final dto = await _post('verification/confirm', {
|
||||||
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
'token': token,
|
||||||
|
}, verification: true);
|
||||||
|
return BackendMapper.activation(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
const DummyVerificationService({
|
const DummyVerificationService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.sendFailure,
|
this.sendFailure,
|
||||||
|
this.verifiedEmail = 'alex@example.com',
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
bool get isSimulated => true;
|
bool get isSimulated => true;
|
||||||
final Duration delay;
|
final Duration delay;
|
||||||
final VerificationError? sendFailure;
|
final VerificationError? sendFailure;
|
||||||
|
final String verifiedEmail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
switch (token) {
|
switch (token) {
|
||||||
case 'demo-valid':
|
case 'demo-valid':
|
||||||
return VerificationMapper.fromDTO(
|
return VerificationMapper.fromDTO(
|
||||||
const VerificationDTO(simulated: true),
|
VerificationDTO(simulated: true, email: verifiedEmail),
|
||||||
);
|
);
|
||||||
case 'demo-expired':
|
case 'demo-expired':
|
||||||
throw const VerificationException(VerificationError.expired);
|
throw const VerificationException(VerificationError.expired);
|
||||||
|
|||||||
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||||
http.Response(jsonEncode({'data': data}), status);
|
http.Response(jsonEncode({'data': data}), status);
|
||||||
|
test(
|
||||||
|
'activation maps only a confirmed response to the verified email',
|
||||||
|
() async {
|
||||||
|
var confirmed = true;
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.url.path, '/api/v1/accounts/verification/confirm');
|
||||||
|
expect(jsonDecode(request.body), {'token': 'opaque-link'});
|
||||||
|
return response({
|
||||||
|
'verified': confirmed,
|
||||||
|
'email': 'alias@example.com',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
final accepted = await service.confirm(token: 'opaque-link');
|
||||||
|
expect(accepted.verifiedEmail, 'alias@example.com');
|
||||||
|
expect(accepted.isSimulated, isFalse);
|
||||||
|
confirmed = false;
|
||||||
|
await expectLater(
|
||||||
|
service.confirm(token: 'opaque-link'),
|
||||||
|
throwsA(isA<VerificationException>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'activation remains compatible with a verified response without email',
|
||||||
|
() async {
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((_) async => response({'verified': true})),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
expect(
|
||||||
|
(await service.confirm(token: 'opaque-link')).verifiedEmail,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
test(
|
test(
|
||||||
'signup distinguishes support recovery and keeps the retry key',
|
'signup distinguishes support recovery and keeps the retry key',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ void main() {
|
|||||||
addTearDown(provider.dispose);
|
addTearDown(provider.dispose);
|
||||||
await provider.confirm(scenario.key);
|
await provider.confirm(scenario.key);
|
||||||
expect(provider.state.status, scenario.value);
|
expect(provider.state.status, scenario.value);
|
||||||
|
expect(
|
||||||
|
provider.state.result?.verifiedEmail,
|
||||||
|
scenario.value == VerificationStatus.verified
|
||||||
|
? 'alex@example.com'
|
||||||
|
: null,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
provider.state.isSimulated,
|
provider.state.isSimulated,
|
||||||
scenario.value == VerificationStatus.verified,
|
scenario.value == VerificationStatus.verified,
|
||||||
@@ -75,7 +81,23 @@ void main() {
|
|||||||
service.pending.complete(const VerificationResult(isSimulated: true));
|
service.pending.complete(const VerificationResult(isSimulated: true));
|
||||||
await operation;
|
await operation;
|
||||||
expect(provider.state.status, VerificationStatus.idle);
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
});
|
});
|
||||||
|
test(
|
||||||
|
'a later failed confirmation clears the previous verified identity',
|
||||||
|
() async {
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
const DummyVerificationService(delay: Duration.zero),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.confirm('demo-valid');
|
||||||
|
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
|
||||||
|
final operation = provider.confirm('bad');
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
await operation;
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('dispose discards late exception', () async {
|
test('dispose discards late exception', () async {
|
||||||
final service = PendingService();
|
final service = PendingService();
|
||||||
final provider = VerificationProvider(service);
|
final provider = VerificationProvider(service);
|
||||||
|
|||||||
Reference in new issue
Block a user