28 changed files with 773 additions and 149 deletions

No files matched your search

+12 -4
View File
@@ -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()}})
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 {
return verification.ErorrTokenNotFound()
return "", verification.ErorrTokenNotFound()
}
var email string
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
if err != nil {
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
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 {
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)
}
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 {
h := sha256.Sum256([]byte(raw))
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
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"}
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}
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)
require.NoError(t, err)
require.False(t, a.IsActive())
guard.block = false
w = post("verification/confirm", map[string]string{"token": activation})
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)
require.NoError(t, err)
require.Equal(t, customer.AccountActive, a.Status)
+147
View File
@@ -149,3 +149,150 @@ text uses a normal text node to remain exposed to assistive technology.
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
demo status, status heading and recovery actions. PR is open for review.
## Browser credential autofill
Task: [#56](https://git.sendico.io/tech/sxchnge/issues/56), branch `auth-56`.
Delivery: [PR #58](https://git.sendico.io/tech/sxchnge/pulls/58), implementation
commit `86b6aaf`. Open for review; no merge or deployment is included.
The consumer auth form groups its mounted inputs in one `AutofillGroup` and
declares their purpose explicitly:
| Input | Flutter hints | Browser autocomplete |
| --- | --- | --- |
| Signup name | `name` with the name keyboard | `name` |
| Account email | `username`, then `email`, with the email keyboard | `username` |
| Login password | `password` | `current-password` |
| Signup password and confirmation | `newPassword` on both | `new-password` |
Flutter uses the first hint on web and Apple platforms. Email is the credential
username; the email keyboard remains configured separately. Visibility toggles
keep the password role, so autofill does not depend on detecting masked input.
The confirmation field participates in the same group as the first password,
allowing the browser to fill both without copying one controller into the other.
Signup acceptance finalizes autofill before clearing password controllers and
navigating to email verification. Login finalizes on success; when a second
factor is required, it waits for verification and login completion. The password
remains only in the existing form controller during that challenge, then clears
on completed navigation or cancellation. Invalid input, rejected credentials and
failed verification do not request saving. Switching auth mode, abandoning the
form or leaving for unverified-account recovery cancels the autofill context.
Provider/service/model boundaries and existing request/outcome telemetry are
unchanged; no field events or credential telemetry are added. Browser popup
display and pre-submit autofill abandonment remain outside current measurement.
### Verification and release check
Focused widget tests send a platform autofill response with all four signup
values and check EN/RU controller updates, grouped credential metadata, visibility
changes, mode switching and abandonment. Auth tests cover validation failures,
service rejection/retry, stale responses, signup/login acceptance, unverified
recovery, invalid second-factor codes and cancellation without premature saving.
Existing narrow/desktop layouts and enlarged-text tests cover the unchanged UI.
Native Safari verification remains a release check: computer-control permission
was unavailable in this session. On the deployed HTTPS origin with Safari
AutoFill enabled, focus the signup email and password fields, choose a suggested
strong password, verify that both password fields populate, then confirm that
login offers the saved credential. Repeat after showing/hiding passwords and
switching modes. Browser settings, stored credentials and browser heuristics
determine whether suggestions appear; automated Flutter tests cannot establish
that the system popup was displayed or that credentials were saved.
References: [Apple Password AutoFill](https://developer.apple.com/documentation/security/enabling-password-autofill-on-an-html-input-element),
[Flutter autofill hints](https://api.flutter.dev/flutter/material/TextField/autofillHints.html),
and [AutofillGroup](https://api.flutter.dev/flutter/widgets/AutofillGroup-class.html).
Recorded validation: `dart format --output=none --set-exit-if-changed` passes for
all five changed Dart files; `flutter analyze` passes in `frontend/app`;
`make test-frontend` passes all 212 tests across the app and shared packages.
`flutter build web` passes. The built page's default rendering path was inspected
in the in-app browser: login exposes `username`/`current-password` in one HTML
form; signup exposes `username`, `name` and two `new-password` inputs in one form.
This verifies the browser metadata, not Safari's credential popup. With Flutter
web accessibility semantics enabled, unfocused semantic inputs initially use
framework-default autocomplete values until focused; password-manager behavior
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.
### Validation and local release (#60)
Delivery: [PR #61](https://git.sendico.io/tech/sxchnge/pulls/61), implementation
commit `5f3b443`. Reviewed the complete backend and frontend contribution against
their module guidelines and the UX/observability checklists. No task-caused
review findings remain. Existing recovery, loading and accessible status states
are retained; EN/RU widget tests and existing small-screen/text-scale coverage
pass. Browser startup was checked against a separately built demo with synthetic
tokens: direct success remains visible, Continue to login shows the verified
email with an empty focused password, and an expired link offers resend/back.
Native Safari was unavailable to automation, so browser-specific helper behavior
is not claimed as verified by this check.
On 2026-10-19, changed-file Dart formatting, `flutter analyze` in both affected
packages (`frontend/app`, `frontend/packages/sxchnge_auth`), `make test-frontend`
(217 tests), `GOFLAGS=-p=1 make test`, and `make lint-backend` passed. The focused
transactional activation integration test also passed independently. Initial
test-container startup and image-resolution attempts failed while Docker was
stalled. After the user-authorized Docker Desktop restart, the unchanged checks
passed; no assertions, timeouts or gates were weakened. No security scan was
requested or run.
The task branch was integrated locally into the existing `identity-facts-57`
stand checkout, preserving its unrelated configuration. Fresh stable image
versions/digests were resolved successfully with `sh infra/local/tools.sh images`.
Compose rebuilt BFF and frontend with `build --pull bff frontend`, then recreated
them and the Sendico session sidecar (which shares BFF's network namespace).
Docker restart recovery also unsealed Sendico's existing Vault with its saved
local key and restarted affected existing services in dependency order.
The updated BFF, frontend and Sendico sidecar are healthy. The authenticated
Sendico currency/ledger check passes. HTTPS serves the direct verification path;
the served JavaScript matches the new frontend image with SHA-256
`b194c0cf5dcc62634c13c7fd3d2f7e482272cb7abc1e470c62629400bf2d5d21`.
A live synthetic invalid token returns HTTP 400 without an email. The supplied
real token was checked read-only and remains unused and unexpired, with its
account pending verification, ready for the user to reopen the link. PR #61 has
not been merged into remote main; this local deployment does not imply a main
branch merge.
+150 -119
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
@@ -18,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();
@@ -32,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 =>
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
void _switch(bool signUp) {
if (signUp == _signUp) return;
FocusScope.of(context).unfocus();
TextInput.finishAutofillContext(shouldSave: false);
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
context.read<LoginProvider>().reset();
context.read<SignupProvider>().reset();
@@ -95,6 +99,7 @@ class _AuthFormState extends State<AuthForm> {
return;
}
final email = _email.text.trim();
TextInput.finishAutofillContext();
_password.clear();
_confirmation.clear();
context.goNamed(AppRoutes.verificationSent, extra: email);
@@ -107,13 +112,13 @@ class _AuthFormState extends State<AuthForm> {
);
if (!mounted || _signUp) return;
if (login.state.error == AuthError.accountNotVerified) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
return;
}
final challenge = login.state.result?.challenge;
if (challenge != null) {
_password.clear();
final grant = await verifyFactor(
context,
challenge: challenge,
@@ -123,6 +128,8 @@ class _AuthFormState extends State<AuthForm> {
);
if (!mounted || _signUp) return;
if (grant == null) {
TextInput.finishAutofillContext(shouldSave: false);
_password.clear();
login.reset();
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
return;
@@ -137,9 +144,13 @@ class _AuthFormState extends State<AuthForm> {
!_signUp &&
identical(login.state.result, accepted) &&
login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
_password.clear();
context.go(offer ? '/identity?onboarding=1' : '/accounts');
}
}
} else if (login.state.status == AuthStatus.success) {
TextInput.finishAutofillContext();
}
}
}
@@ -195,150 +206,170 @@ class _AuthFormState extends State<AuthForm> {
expandedInsets: EdgeInsets.zero,
),
const SizedBox(height: 32),
Form(
key: _form,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
onChanged: () {
if (_provider.state.status == AuthStatus.success ||
_provider.state.status == AuthStatus.error) {
_provider.reset();
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
AutofillGroup(
// Leaving an unfinished form must not offer to save its credentials.
onDisposeAction: AutofillContextAction.cancel,
child: Form(
key: _form,
autovalidateMode: _submitted
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
onChanged: () {
if (_provider.state.status == AuthStatus.success ||
_provider.state.status == AuthStatus.error) {
_provider.reset();
}
},
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_signUp) ...[
TextFormField(
key: const ValueKey('name'),
controller: _name,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
errorMaxLines: 3,
),
textInputAction: TextInputAction.next,
keyboardType: TextInputType.name,
autofillHints: const [AutofillHints.name],
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
),
const SizedBox(height: 20),
],
TextFormField(
key: const ValueKey('name'),
controller: _name,
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.name,
prefixIcon: Icon(Icons.person_outline),
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
autofillHints: const [
AutofillHints.username,
AutofillHints.email,
],
textInputAction: TextInputAction.next,
textCapitalization: TextCapitalization.words,
validator: (value) => AuthValidation.name(value, l),
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
const SizedBox(height: 20),
],
TextFormField(
key: const ValueKey('email'),
controller: _email,
enabled: !state.isLoading,
decoration: InputDecoration(
labelText: l.email,
hintText: l.emailHint,
prefixIcon: Icon(Icons.alternate_email),
errorMaxLines: 3,
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autocorrect: false,
validator: (value) => AuthValidation.email(value, l),
),
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _password,
obscureText: !_showPassword,
autocorrect: false,
enableSuggestions: false,
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.password(value, l, signUp: _signUp),
),
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('confirmation'),
key: const ValueKey('password'),
enabled: !state.isLoading,
controller: _confirmation,
obscureText: !_showConfirmation,
controller: _password,
autofocus: widget.email?.isNotEmpty ?? false,
obscureText: !_showPassword,
autofillHints: [
_signUp
? AutofillHints.newPassword
: AutofillHints.password,
],
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
textInputAction: _signUp
? TextInputAction.next
: TextInputAction.done,
onFieldSubmitted: (_) {
if (!_signUp) _submit();
},
decoration: InputDecoration(
labelText: l.confirmPassword,
labelText: l.password,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
helperText: _signUp ? l.passwordHint : null,
helperMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
tooltip: _showPassword ? l.hidePassword : l.showPassword,
onPressed: () =>
setState(() => _showPassword = !_showPassword),
icon: Icon(
_showConfirmation
_showPassword
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
),
validator: (value) =>
AuthValidation.confirmation(value, _password.text, l),
AuthValidation.password(value, l, signUp: _signUp),
),
],
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('submit'),
onPressed: state.isLoading || state.status == AuthStatus.success
? null
: _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
state.isLoading
? l.authWorking
: (_signUp ? l.createAccount : l.letsGo),
textAlign: TextAlign.center,
if (_signUp) ...[
const SizedBox(height: 20),
TextFormField(
key: const ValueKey('confirmation'),
enabled: !state.isLoading,
controller: _confirmation,
obscureText: !_showConfirmation,
autofillHints: const [AutofillHints.newPassword],
autocorrect: false,
enableSuggestions: false,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _submit(),
decoration: InputDecoration(
labelText: l.confirmPassword,
prefixIcon: const Icon(Icons.lock_outline),
errorMaxLines: 3,
suffixIcon: IconButton(
tooltip: _showConfirmation
? l.hideConfirmation
: l.showConfirmation,
onPressed: () => setState(
() => _showConfirmation = !_showConfirmation,
),
icon: Icon(
_showConfirmation
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
),
),
const SizedBox(width: 12),
state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
validator: (value) =>
AuthValidation.confirmation(value, _password.text, l),
),
],
const SizedBox(height: 28),
FilledButton(
key: const ValueKey('submit'),
onPressed:
state.isLoading || state.status == AuthStatus.success
? null
: _submit,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: Text(
state.isLoading
? l.authWorking
: (_signUp ? l.createAccount : l.letsGo),
textAlign: TextAlign.center,
),
),
const SizedBox(width: 12),
state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.arrow_forward, size: 20),
],
),
),
),
),
],
],
),
),
),
if (state.status == AuthStatus.success ||
+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:
+38
View File
@@ -92,6 +92,14 @@ void main() {
findsOneWidget,
);
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(
tester
.widget<TextFormField>(find.byKey(const ValueKey('email')))
@@ -153,6 +161,14 @@ void main() {
'/verification-sent',
);
expect(backend.sends, 1);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(
find.textContaining('a verification email is on its way'),
findsOneWidget,
@@ -198,6 +214,14 @@ void main() {
find.text('Сервис недоступен. Попробуйте ещё раз.'),
findsOneWidget,
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.tap(button);
await tester.pump();
expect(service.calls, hasLength(2));
@@ -207,6 +231,12 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull);
},
);
@@ -237,6 +267,14 @@ void main() {
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(tester.takeException(), isNull);
});
}
+28 -2
View File
@@ -55,6 +55,12 @@ void main() {
await submit(tester);
expect(find.text('Enter your email address.'), findsOneWidget);
expect(find.text('Enter your password.'), findsOneWidget);
expect(
tester.testTextInput.log.where(
(call) => call.method == 'TextInput.finishAutofillContext',
),
isEmpty,
);
await tester.enterText(
find.byKey(const ValueKey('email')),
'test@example.com',
@@ -79,6 +85,12 @@ void main() {
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
expect(tester.takeException(), isNull);
},
);
@@ -104,12 +116,26 @@ void main() {
);
await submit(tester);
expect(find.text('Passwords do not match.'), findsOneWidget);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await tester.enterText(
find.byKey(const ValueKey('confirmation')),
'example-password',
);
await submit(tester);
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
await tester.ensureVisible(
find.byKey(const ValueKey('demo-confirmation')),
);
@@ -119,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);
+149
View File
@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
Finder field(String key) => find.byKey(ValueKey(key));
EditableTextState editor(WidgetTester tester, String key) =>
tester.state<EditableTextState>(
find.descendant(of: field(key), matching: find.byType(EditableText)),
);
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'platform autofill fills the signup credential group ($language)',
(tester) async {
tester.view.physicalSize = const Size(1440, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
SxchngeApp(
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.text(language == 'ru' ? 'Регистрация' : 'Sign up'),
);
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final configuration = tester.testTextInput.setClientArgs!;
expect(
configuration['inputType']['name'],
'TextInputType.emailAddress',
);
final fields = (configuration['fields'] as List).cast<Map>();
expect(fields, hasLength(4));
expect(
fields.map((field) => field['autofill']['hints'].first),
containsAll(['name', 'username', 'newPassword', 'newPassword']),
);
const values = {
'name': 'Alex Example',
'email': 'alex@example.com',
'password': 'generated-example-password',
'confirmation': 'generated-example-password',
};
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
SystemChannels.textInput.name,
SystemChannels.textInput.codec.encodeMethodCall(
MethodCall('TextInputClient.updateEditingStateWithTag', [
0,
{
for (final entry in values.entries)
editor(tester, entry.key).autofillId: TextEditingValue(
text: entry.value,
).toJSON(),
},
]),
),
(_) {},
);
await tester.pumpAndSettle();
for (final entry in values.entries) {
expect(
editor(tester, entry.key).currentTextEditingValue.text,
entry.value,
);
}
// A visibility change must retain both the supplied value and its role.
await tester.tap(
find.byTooltip(
language == 'ru' ? 'Показать пароль' : 'Show password',
),
);
await tester.pump();
expect(editor(tester, 'password').widget.obscureText, isFalse);
expect(editor(tester, 'password').widget.autofillHints, [
'newPassword',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
values['password'],
);
await tester.tap(find.text(language == 'ru' ? 'Войти' : 'Log in'));
await tester.pumpAndSettle();
await tester.showKeyboard(field('email'));
final loginFields =
(tester.testTextInput.setClientArgs!['fields'] as List).cast<Map>();
expect(loginFields, hasLength(2));
expect(loginFields.map((field) => field['autofill']['hints'].first), [
'username',
'password',
]);
expect(
editor(tester, 'password').currentTextEditingValue.text,
isEmpty,
);
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
},
);
}
testWidgets('leaving an unfinished auth form cancels saving', (tester) async {
await tester.pumpWidget(const SxchngeApp());
await tester.pumpAndSettle();
await tester.enterText(field('email'), 'alex@example.com');
await tester.enterText(field('password'), 'unfinished-password');
tester.testTextInput.log.clear();
await tester.pumpWidget(const SizedBox.shrink());
await tester.pumpAndSettle();
expect(
tester.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
tester.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
});
}
+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 {
+39
View File
@@ -148,13 +148,38 @@ void main() {
await signIn(t);
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
isEmpty,
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
await tapFactor(t, 'two-factor-confirm');
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
);
final login = analytics.events
.where((e) => e.kind == JourneyKind.login)
.toList();
@@ -172,6 +197,20 @@ void main() {
await signIn(t);
await tapFactor(t, 'two-factor-cancel');
expect(find.byKey(const ValueKey('email')), findsOneWidget);
expect(
t.testTextInput.log,
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: false),
),
);
expect(
t.testTextInput.log,
isNot(
contains(
isMethodCall('TextInput.finishAutofillContext', arguments: true),
),
),
);
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
expect(
t
@@ -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);