Complete verification links and preserve verified login email (#60)

This commit is contained in:
Stephan D committed 2026-10-19 14:34:00 +02:00
1 parent 30f06c2f8f
commit 5f3b443e7b
25 files changed
+270 -32

No files matched your search

+4 -2
View File
@@ -19,7 +19,8 @@ import 'validation.dart';
import '../generated/i18n/app_localizations.dart';
class AuthForm extends StatefulWidget {
const AuthForm({super.key});
const AuthForm({super.key, this.email});
final String? email;
@override
State<AuthForm> createState() => _AuthFormState();
@@ -33,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
bool _showPassword = false;
bool _showConfirmation = false;
bool _submitted = false;
final _email = TextEditingController();
late final _email = TextEditingController(text: widget.email);
final _name = TextEditingController();
AuthProvider get _provider =>
@@ -264,6 +265,7 @@ class _AuthFormState extends State<AuthForm> {
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _password,
autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword,
autofillHints: [
_signUp
+3 -2
View File
@@ -10,7 +10,8 @@ import 'form.dart';
import '../generated/i18n/app_localizations.dart';
class AuthPage extends StatefulWidget {
const AuthPage({super.key});
const AuthPage({super.key, this.email});
final String? email;
@override
State<AuthPage> createState() => _AuthPageState();
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: AuthForm(key: _formKey),
child: AuthForm(key: _formKey, email: widget.email),
),
),
],
+1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Send a new verification email",
"verificationRetry": "Retry verification",
"verificationBack": "Back to login",
"verificationContinue": "Continue to login",
"verificationHint": "No email? Check your spam folder or request another below.",
"verificationDemoLink": "Continue to confirmation",
"verificationStep": "CONFIRM YOUR EMAIL",
+1
View File
@@ -63,6 +63,7 @@
"verificationRequest": "Отправить новое письмо",
"verificationRetry": "Повторить подтверждение",
"verificationBack": "Вернуться ко входу",
"verificationContinue": "Перейти ко входу",
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
"verificationDemoLink": "Перейти к подтверждению",
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
+3
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/url_strategy.dart';
import 'package:provider/provider.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
@@ -18,6 +20,7 @@ import 'routing/router.dart';
import 'generated/i18n/app_localizations.dart';
Future<void> main() async {
usePathUrlStrategy();
final binding = WidgetsFlutterBinding.ensureInitialized();
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
+3 -1
View File
@@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
GoRoute(
path: '/',
name: AppRoutes.login,
builder: (context, state) => const AuthPage(),
builder: (context, state) => AuthPage(
email: state.extra is String ? state.extra as String : null,
),
),
GoRoute(
path: '/verification-sent',
+13 -7
View File
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
if (verified) ...[
const SizedBox(height: 16),
FilledButton(
onPressed: () => context.go('/accounts'),
child: Text(l.wEnter),
key: const ValueKey('verification-continue'),
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),
),
],
),
),
+1 -1
View File
@@ -185,7 +185,7 @@ packages:
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
+2
View File
@@ -14,6 +14,8 @@ dependencies:
path: ../packages/sxchnge_analytics
flutter:
sdk: flutter
flutter_web_plugins:
sdk: flutter
sxchnge_auth:
path: ../packages/sxchnge_auth
sxchnge_theme:
+2 -2
View File
@@ -145,8 +145,8 @@ void main() {
find.byKey(const ValueKey('verification-verified')),
findsOneWidget,
);
await tester.ensureVisible(find.text('Back to login'));
await tester.tap(find.text('Back to login'));
await tester.ensureVisible(find.text('Continue to login'));
await tester.tap(find.text('Continue to login'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
expect(find.text('example-password'), findsNothing);
+61
View File
@@ -42,6 +42,67 @@ Future<GoRouter> openRoute(
}
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', (
tester,
) async {
@@ -6,6 +6,8 @@ class BackendDTO {
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
);
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 userId =>
data['userId'] as String? ??
@@ -6,6 +6,7 @@ class AuthDTO {
}
class VerificationDTO {
const VerificationDTO({required this.simulated});
const VerificationDTO({required this.simulated, this.email});
final bool simulated;
final String? email;
}
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
import '../models/verification.dart';
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) {
if (dto.userId.isEmpty) throw const FormatException('Missing user');
return AuthResult(userId: dto.userId, isSimulated: false);
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
abstract final class VerificationMapper {
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 {
const VerificationResult({required this.isSimulated});
const VerificationResult({required this.isSimulated, this.verifiedEmail});
final bool isSimulated;
final String? verifiedEmail;
}
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 VerificationError? error;
final bool isSimulated;
final VerificationResult? result;
bool get isLoading =>
status == VerificationStatus.sending ||
status == VerificationStatus.checking;
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
try {
final result = await operation();
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) {
if (_disposed || generation != _generation) return;
final code = error is VerificationException
@@ -172,8 +172,10 @@ class BackendAuthServices
@override
Future<VerificationResult> confirm({required String token}) async {
await _post('verification/confirm', {'token': token}, verification: true);
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
final dto = await _post('verification/confirm', {
'token': token,
}, verification: true);
return BackendMapper.activation(dto);
}
@override
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
const DummyVerificationService({
this.delay = const Duration(milliseconds: 600),
this.sendFailure,
this.verifiedEmail = 'alex@example.com',
});
@override
bool get isSimulated => true;
final Duration delay;
final VerificationError? sendFailure;
final String verifiedEmail;
@override
Future<VerificationResult> send({
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
switch (token) {
case 'demo-valid':
return VerificationMapper.fromDTO(
const VerificationDTO(simulated: true),
VerificationDTO(simulated: true, email: verifiedEmail),
);
case 'demo-expired':
throw const VerificationException(VerificationError.expired);
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
void main() {
http.Response response(Map<String, Object?> data, [int status = 202]) =>
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(
'signup distinguishes support recovery and keeps the retry key',
() async {
@@ -42,6 +42,12 @@ void main() {
addTearDown(provider.dispose);
await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value);
expect(
provider.state.result?.verifiedEmail,
scenario.value == VerificationStatus.verified
? 'alex@example.com'
: null,
);
expect(
provider.state.isSimulated,
scenario.value == VerificationStatus.verified,
@@ -75,7 +81,23 @@ void main() {
service.pending.complete(const VerificationResult(isSimulated: true));
await operation;
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 {
final service = PendingService();
final provider = VerificationProvider(service);