Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7b75d1b8 | ||
|
|
a587cbbc24 | ||
|
|
5f3b443e7b | ||
|
|
30f06c2f8f | ||
|
|
c6ab4680fd | ||
|
|
86b6aafe7d |
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()}})
|
||||
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)
|
||||
|
||||
@@ -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
@@ -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 ||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"verificationRequest": "Отправить новое письмо",
|
||||
"verificationRetry": "Повторить подтверждение",
|
||||
"verificationBack": "Вернуться ко входу",
|
||||
"verificationContinue": "Перейти ко входу",
|
||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||
"verificationDemoLink": "Перейти к подтверждению",
|
||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user