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()}})
|
err = s.Sender.Send(ctx, notifications.Message{Key: secretKey, UserID: a.ID.Hex(), Email: a.Login, Locale: locale, Template: "auth.activation", ExpiresAt: result.ExpiresAt, Parameters: map[string]string{"url": u.String()}})
|
||||||
return result.ExpiresAt, err
|
return result.ExpiresAt, err
|
||||||
}
|
}
|
||||||
func (s *Service) Activate(ctx context.Context, raw string) error {
|
func (s *Service) Activate(ctx context.Context, raw string) (string, error) {
|
||||||
if len(raw) != 43 {
|
if len(raw) != 43 {
|
||||||
return verification.ErorrTokenNotFound()
|
return "", verification.ErorrTokenNotFound()
|
||||||
}
|
}
|
||||||
|
var email string
|
||||||
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
_, err := s.Transactions.CreateTransaction().Execute(ctx, func(tx context.Context) (any, error) {
|
||||||
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
token, err := s.Tokens.Consume(tx, bson.NilObjectID, verificationmodel.PurposeAccountActivation, "", raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +201,14 @@ func (s *Service) Activate(ctx context.Context, raw string) error {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil, s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive))
|
if err := s.Accounts.Patch(tx, a.ID, repository.Patch().Set(repository.Field("status"), customer.AccountActive)); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
email = a.Login
|
||||||
|
return nil, nil
|
||||||
})
|
})
|
||||||
return err
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return email, nil
|
||||||
}
|
}
|
||||||
@@ -139,11 +139,15 @@ func (pr *PublicRouter) activate(r *http.Request) http.HandlerFunc {
|
|||||||
if err := decodeFlow(r, &req); err != nil {
|
if err := decodeFlow(r, &req); err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
if err := pr.flow.Activate(r.Context(), req.Token); err != nil {
|
email, err := pr.flow.Activate(r.Context(), req.Token)
|
||||||
|
if err != nil {
|
||||||
return pr.flowError(err)
|
return pr.flowError(err)
|
||||||
}
|
}
|
||||||
pr.logger.Info("Account activation completed")
|
pr.logger.Info("Account activation completed")
|
||||||
return response.Accepted(pr.logger, map[string]bool{"verified": true})
|
return response.Accepted(pr.logger, struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}{Verified: true, Email: email})
|
||||||
}
|
}
|
||||||
func challengeScope(raw string) string {
|
func challengeScope(raw string) string {
|
||||||
h := sha256.Sum256([]byte(raw))
|
h := sha256.Sum256([]byte(raw))
|
||||||
|
|||||||
@@ -152,17 +152,33 @@ func TestSignupActivationAndLoginEmailFlow(t *testing.T) {
|
|||||||
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
require.NoError(t, service.Facts.Ready(ctx, a.ID))
|
||||||
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
login := map[string]any{"login": map[string]string{"login": "user@example.com", "password": " correct password "}, "clientId": "web", "deviceId": "browser"}
|
||||||
require.Equal(t, 403, post("login", login).Code)
|
require.Equal(t, 403, post("login", login).Code)
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": "invalid"}).Code)
|
invalid := post("verification/confirm", map[string]string{"token": "invalid"})
|
||||||
|
require.Equal(t, 400, invalid.Code)
|
||||||
|
require.NotContains(t, invalid.Body.String(), a.Login)
|
||||||
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
guard := &activationGuard{SignupFacts: service.Facts, block: true}
|
||||||
service.Facts = guard
|
service.Facts = guard
|
||||||
require.Equal(t, 503, post("verification/confirm", map[string]string{"token": activation}).Code)
|
blocked := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 503, blocked.Code)
|
||||||
|
require.NotContains(t, blocked.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, a.IsActive())
|
require.False(t, a.IsActive())
|
||||||
guard.block = false
|
guard.block = false
|
||||||
w = post("verification/confirm", map[string]string{"token": activation})
|
w = post("verification/confirm", map[string]string{"token": activation})
|
||||||
require.Equal(t, 202, w.Code, w.Body.String())
|
require.Equal(t, 202, w.Code, w.Body.String())
|
||||||
require.Equal(t, 400, post("verification/confirm", map[string]string{"token": activation}).Code)
|
require.Equal(t, "no-store", w.Header().Get("Cache-Control"))
|
||||||
|
var confirmed struct {
|
||||||
|
Data struct {
|
||||||
|
Verified bool `json:"verified"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &confirmed))
|
||||||
|
require.True(t, confirmed.Data.Verified)
|
||||||
|
require.Equal(t, a.Login, confirmed.Data.Email)
|
||||||
|
used := post("verification/confirm", map[string]string{"token": activation})
|
||||||
|
require.Equal(t, 400, used.Code)
|
||||||
|
require.NotContains(t, used.Body.String(), a.Login)
|
||||||
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
a, err = stores.Accounts.GetByEmail(ctx, a.Login)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, customer.AccountActive, a.Status)
|
require.Equal(t, customer.AccountActive, a.Status)
|
||||||
|
|||||||
@@ -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
|
Delivered in [PR #13](https://git.sendico.io/tech/sxchnge/pulls/13), branch
|
||||||
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
`onboarding-12`. The final browser accessibility tree exposes the recipient text,
|
||||||
demo status, status heading and recovery actions. PR is open for review.
|
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/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -18,7 +19,8 @@ import 'validation.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthForm extends StatefulWidget {
|
class AuthForm extends StatefulWidget {
|
||||||
const AuthForm({super.key});
|
const AuthForm({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthForm> createState() => _AuthFormState();
|
State<AuthForm> createState() => _AuthFormState();
|
||||||
@@ -32,7 +34,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
bool _showPassword = false;
|
bool _showPassword = false;
|
||||||
bool _showConfirmation = false;
|
bool _showConfirmation = false;
|
||||||
bool _submitted = false;
|
bool _submitted = false;
|
||||||
final _email = TextEditingController();
|
late final _email = TextEditingController(text: widget.email);
|
||||||
final _name = TextEditingController();
|
final _name = TextEditingController();
|
||||||
|
|
||||||
AuthProvider get _provider =>
|
AuthProvider get _provider =>
|
||||||
@@ -63,6 +65,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
|
|
||||||
void _switch(bool signUp) {
|
void _switch(bool signUp) {
|
||||||
if (signUp == _signUp) return;
|
if (signUp == _signUp) return;
|
||||||
|
FocusScope.of(context).unfocus();
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
context.read<JourneyRecorder>().abandon(JourneyExit.modeChange);
|
||||||
context.read<LoginProvider>().reset();
|
context.read<LoginProvider>().reset();
|
||||||
context.read<SignupProvider>().reset();
|
context.read<SignupProvider>().reset();
|
||||||
@@ -95,6 +99,7 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final email = _email.text.trim();
|
final email = _email.text.trim();
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
_password.clear();
|
_password.clear();
|
||||||
_confirmation.clear();
|
_confirmation.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: email);
|
context.goNamed(AppRoutes.verificationSent, extra: email);
|
||||||
@@ -107,13 +112,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (login.state.error == AuthError.accountNotVerified) {
|
if (login.state.error == AuthError.accountNotVerified) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
_password.clear();
|
_password.clear();
|
||||||
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
context.goNamed(AppRoutes.verificationSent, extra: _email.text.trim());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final challenge = login.state.result?.challenge;
|
final challenge = login.state.result?.challenge;
|
||||||
if (challenge != null) {
|
if (challenge != null) {
|
||||||
_password.clear();
|
|
||||||
final grant = await verifyFactor(
|
final grant = await verifyFactor(
|
||||||
context,
|
context,
|
||||||
challenge: challenge,
|
challenge: challenge,
|
||||||
@@ -123,6 +128,8 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
);
|
);
|
||||||
if (!mounted || _signUp) return;
|
if (!mounted || _signUp) return;
|
||||||
if (grant == null) {
|
if (grant == null) {
|
||||||
|
TextInput.finishAutofillContext(shouldSave: false);
|
||||||
|
_password.clear();
|
||||||
login.reset();
|
login.reset();
|
||||||
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
context.read<JourneyRecorder>().abandon(JourneyExit.navigation);
|
||||||
return;
|
return;
|
||||||
@@ -137,9 +144,13 @@ class _AuthFormState extends State<AuthForm> {
|
|||||||
!_signUp &&
|
!_signUp &&
|
||||||
identical(login.state.result, accepted) &&
|
identical(login.state.result, accepted) &&
|
||||||
login.state.status == AuthStatus.success) {
|
login.state.status == AuthStatus.success) {
|
||||||
|
TextInput.finishAutofillContext();
|
||||||
|
_password.clear();
|
||||||
context.go(offer ? '/identity?onboarding=1' : '/accounts');
|
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,
|
expandedInsets: EdgeInsets.zero,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
Form(
|
AutofillGroup(
|
||||||
key: _form,
|
// Leaving an unfinished form must not offer to save its credentials.
|
||||||
autovalidateMode: _submitted
|
onDisposeAction: AutofillContextAction.cancel,
|
||||||
? AutovalidateMode.onUserInteraction
|
child: Form(
|
||||||
: AutovalidateMode.disabled,
|
key: _form,
|
||||||
onChanged: () {
|
autovalidateMode: _submitted
|
||||||
if (_provider.state.status == AuthStatus.success ||
|
? AutovalidateMode.onUserInteraction
|
||||||
_provider.state.status == AuthStatus.error) {
|
: AutovalidateMode.disabled,
|
||||||
_provider.reset();
|
onChanged: () {
|
||||||
}
|
if (_provider.state.status == AuthStatus.success ||
|
||||||
},
|
_provider.state.status == AuthStatus.error) {
|
||||||
child: Column(
|
_provider.reset();
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
}
|
||||||
children: [
|
},
|
||||||
if (_signUp) ...[
|
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(
|
TextFormField(
|
||||||
key: const ValueKey('name'),
|
key: const ValueKey('email'),
|
||||||
controller: _name,
|
controller: _email,
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.name,
|
labelText: l.email,
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
hintText: l.emailHint,
|
||||||
|
prefixIcon: Icon(Icons.alternate_email),
|
||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
),
|
),
|
||||||
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
autofillHints: const [
|
||||||
|
AutofillHints.username,
|
||||||
|
AutofillHints.email,
|
||||||
|
],
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
textCapitalization: TextCapitalization.words,
|
autocorrect: false,
|
||||||
validator: (value) => AuthValidation.name(value, l),
|
validator: (value) => AuthValidation.email(value, l),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
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(
|
TextFormField(
|
||||||
key: const ValueKey('confirmation'),
|
key: const ValueKey('password'),
|
||||||
enabled: !state.isLoading,
|
enabled: !state.isLoading,
|
||||||
controller: _confirmation,
|
controller: _password,
|
||||||
obscureText: !_showConfirmation,
|
autofocus: widget.email?.isNotEmpty ?? false,
|
||||||
|
obscureText: !_showPassword,
|
||||||
|
autofillHints: [
|
||||||
|
_signUp
|
||||||
|
? AutofillHints.newPassword
|
||||||
|
: AutofillHints.password,
|
||||||
|
],
|
||||||
autocorrect: false,
|
autocorrect: false,
|
||||||
enableSuggestions: false,
|
enableSuggestions: false,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: _signUp
|
||||||
onFieldSubmitted: (_) => _submit(),
|
? TextInputAction.next
|
||||||
|
: TextInputAction.done,
|
||||||
|
onFieldSubmitted: (_) {
|
||||||
|
if (!_signUp) _submit();
|
||||||
|
},
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: l.confirmPassword,
|
labelText: l.password,
|
||||||
prefixIcon: const Icon(Icons.lock_outline),
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
errorMaxLines: 3,
|
errorMaxLines: 3,
|
||||||
|
helperText: _signUp ? l.passwordHint : null,
|
||||||
|
helperMaxLines: 3,
|
||||||
suffixIcon: IconButton(
|
suffixIcon: IconButton(
|
||||||
tooltip: _showConfirmation
|
tooltip: _showPassword ? l.hidePassword : l.showPassword,
|
||||||
? l.hideConfirmation
|
onPressed: () =>
|
||||||
: l.showConfirmation,
|
setState(() => _showPassword = !_showPassword),
|
||||||
onPressed: () => setState(
|
|
||||||
() => _showConfirmation = !_showConfirmation,
|
|
||||||
),
|
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_showConfirmation
|
_showPassword
|
||||||
? Icons.visibility_off_outlined
|
? Icons.visibility_off_outlined
|
||||||
: Icons.visibility_outlined,
|
: Icons.visibility_outlined,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
validator: (value) =>
|
validator: (value) =>
|
||||||
AuthValidation.confirmation(value, _password.text, l),
|
AuthValidation.password(value, l, signUp: _signUp),
|
||||||
),
|
),
|
||||||
],
|
if (_signUp) ...[
|
||||||
const SizedBox(height: 28),
|
const SizedBox(height: 20),
|
||||||
FilledButton(
|
TextFormField(
|
||||||
key: const ValueKey('submit'),
|
key: const ValueKey('confirmation'),
|
||||||
onPressed: state.isLoading || state.status == AuthStatus.success
|
enabled: !state.isLoading,
|
||||||
? null
|
controller: _confirmation,
|
||||||
: _submit,
|
obscureText: !_showConfirmation,
|
||||||
child: Padding(
|
autofillHints: const [AutofillHints.newPassword],
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
autocorrect: false,
|
||||||
child: Row(
|
enableSuggestions: false,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
textInputAction: TextInputAction.done,
|
||||||
children: [
|
onFieldSubmitted: (_) => _submit(),
|
||||||
Flexible(
|
decoration: InputDecoration(
|
||||||
child: Text(
|
labelText: l.confirmPassword,
|
||||||
state.isLoading
|
prefixIcon: const Icon(Icons.lock_outline),
|
||||||
? l.authWorking
|
errorMaxLines: 3,
|
||||||
: (_signUp ? l.createAccount : l.letsGo),
|
suffixIcon: IconButton(
|
||||||
textAlign: TextAlign.center,
|
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
|
validator: (value) =>
|
||||||
? const SizedBox(
|
AuthValidation.confirmation(value, _password.text, l),
|
||||||
width: 20,
|
),
|
||||||
height: 20,
|
],
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
const SizedBox(height: 28),
|
||||||
)
|
FilledButton(
|
||||||
: const Icon(Icons.arrow_forward, size: 20),
|
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 ||
|
if (state.status == AuthStatus.success ||
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import 'form.dart';
|
|||||||
import '../generated/i18n/app_localizations.dart';
|
import '../generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
class AuthPage extends StatefulWidget {
|
class AuthPage extends StatefulWidget {
|
||||||
const AuthPage({super.key});
|
const AuthPage({super.key, this.email});
|
||||||
|
final String? email;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AuthPage> createState() => _AuthPageState();
|
State<AuthPage> createState() => _AuthPageState();
|
||||||
@@ -56,7 +57,7 @@ class _AuthPageState extends State<AuthPage> {
|
|||||||
Center(
|
Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
constraints: const BoxConstraints(maxWidth: 440),
|
||||||
child: AuthForm(key: _formKey),
|
child: AuthForm(key: _formKey, email: widget.email),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Send a new verification email",
|
"verificationRequest": "Send a new verification email",
|
||||||
"verificationRetry": "Retry verification",
|
"verificationRetry": "Retry verification",
|
||||||
"verificationBack": "Back to login",
|
"verificationBack": "Back to login",
|
||||||
|
"verificationContinue": "Continue to login",
|
||||||
"verificationHint": "No email? Check your spam folder or request another below.",
|
"verificationHint": "No email? Check your spam folder or request another below.",
|
||||||
"verificationDemoLink": "Continue to confirmation",
|
"verificationDemoLink": "Continue to confirmation",
|
||||||
"verificationStep": "CONFIRM YOUR EMAIL",
|
"verificationStep": "CONFIRM YOUR EMAIL",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"verificationRequest": "Отправить новое письмо",
|
"verificationRequest": "Отправить новое письмо",
|
||||||
"verificationRetry": "Повторить подтверждение",
|
"verificationRetry": "Повторить подтверждение",
|
||||||
"verificationBack": "Вернуться ко входу",
|
"verificationBack": "Вернуться ко входу",
|
||||||
|
"verificationContinue": "Перейти ко входу",
|
||||||
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
"verificationHint": "Нет письма? Проверьте папку «Спам» или запросите новое ниже.",
|
||||||
"verificationDemoLink": "Перейти к подтверждению",
|
"verificationDemoLink": "Перейти к подтверждению",
|
||||||
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
"verificationStep": "ПОДТВЕРДИТЕ ПОЧТУ",
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||||
@@ -18,6 +20,7 @@ import 'routing/router.dart';
|
|||||||
import 'generated/i18n/app_localizations.dart';
|
import 'generated/i18n/app_localizations.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
|
usePathUrlStrategy();
|
||||||
final binding = WidgetsFlutterBinding.ensureInitialized();
|
final binding = WidgetsFlutterBinding.ensureInitialized();
|
||||||
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
final locale = await LocaleProvider.load(binding.platformDispatcher.locales);
|
||||||
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
const apiUrl = String.fromEnvironment('SXCHNGE_API_URL');
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ GoRouter createAppRouter({bool Function()? authenticated}) {
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/',
|
path: '/',
|
||||||
name: AppRoutes.login,
|
name: AppRoutes.login,
|
||||||
builder: (context, state) => const AuthPage(),
|
builder: (context, state) => AuthPage(
|
||||||
|
email: state.extra is String ? state.extra as String : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/verification-sent',
|
path: '/verification-sent',
|
||||||
|
|||||||
@@ -318,15 +318,21 @@ class _VerificationPageState extends State<VerificationPage> {
|
|||||||
if (verified) ...[
|
if (verified) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: () => context.go('/accounts'),
|
key: const ValueKey('verification-continue'),
|
||||||
child: Text(l.wEnter),
|
onPressed: () => context.goNamed(
|
||||||
|
AppRoutes.login,
|
||||||
|
extra: state.result?.verifiedEmail,
|
||||||
|
),
|
||||||
|
child: Text(l.verificationContinue),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!verified) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.goNamed(AppRoutes.login),
|
||||||
|
child: Text(l.verificationBack),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => context.goNamed(AppRoutes.login),
|
|
||||||
child: Text(l.verificationBack),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ packages:
|
|||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ dependencies:
|
|||||||
path: ../packages/sxchnge_analytics
|
path: ../packages/sxchnge_analytics
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_web_plugins:
|
||||||
|
sdk: flutter
|
||||||
sxchnge_auth:
|
sxchnge_auth:
|
||||||
path: ../packages/sxchnge_auth
|
path: ../packages/sxchnge_auth
|
||||||
sxchnge_theme:
|
sxchnge_theme:
|
||||||
|
|||||||
@@ -92,6 +92,14 @@ void main() {
|
|||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
expect(find.byKey(const ValueKey('verification-sent')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
tester
|
tester
|
||||||
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
.widget<TextFormField>(find.byKey(const ValueKey('email')))
|
||||||
@@ -153,6 +161,14 @@ void main() {
|
|||||||
'/verification-sent',
|
'/verification-sent',
|
||||||
);
|
);
|
||||||
expect(backend.sends, 1);
|
expect(backend.sends, 1);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
find.textContaining('a verification email is on its way'),
|
find.textContaining('a verification email is on its way'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
@@ -198,6 +214,14 @@ void main() {
|
|||||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.tap(button);
|
await tester.tap(button);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(service.calls, hasLength(2));
|
expect(service.calls, hasLength(2));
|
||||||
@@ -207,6 +231,12 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -237,6 +267,14 @@ void main() {
|
|||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,12 @@ void main() {
|
|||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||||
expect(find.text('Enter your password.'), findsOneWidget);
|
expect(find.text('Enter your password.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log.where(
|
||||||
|
(call) => call.method == 'TextInput.finishAutofillContext',
|
||||||
|
),
|
||||||
|
isEmpty,
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('email')),
|
find.byKey(const ValueKey('email')),
|
||||||
'test@example.com',
|
'test@example.com',
|
||||||
@@ -79,6 +85,12 @@ void main() {
|
|||||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(tester.takeException(), isNull);
|
expect(tester.takeException(), isNull);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -104,12 +116,26 @@ void main() {
|
|||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.enterText(
|
await tester.enterText(
|
||||||
find.byKey(const ValueKey('confirmation')),
|
find.byKey(const ValueKey('confirmation')),
|
||||||
'example-password',
|
'example-password',
|
||||||
);
|
);
|
||||||
await submit(tester);
|
await submit(tester);
|
||||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
tester.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
await tester.ensureVisible(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('demo-confirmation')),
|
find.byKey(const ValueKey('demo-confirmation')),
|
||||||
);
|
);
|
||||||
@@ -119,8 +145,8 @@ void main() {
|
|||||||
find.byKey(const ValueKey('verification-verified')),
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
findsOneWidget,
|
findsOneWidget,
|
||||||
);
|
);
|
||||||
await tester.ensureVisible(find.text('Back to login'));
|
await tester.ensureVisible(find.text('Continue to login'));
|
||||||
await tester.tap(find.text('Back to login'));
|
await tester.tap(find.text('Continue to login'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
expect(find.text('example-password'), findsNothing);
|
expect(find.text('example-password'), findsNothing);
|
||||||
|
|||||||
@@ -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() {
|
void main() {
|
||||||
|
for (final language in ['en', 'ru']) {
|
||||||
|
testWidgets(
|
||||||
|
'verified link keeps success visible and prefills login ($language)',
|
||||||
|
(tester) async {
|
||||||
|
tester.platformDispatcher.defaultRouteNameTestValue =
|
||||||
|
'/verify-email?token=demo-valid';
|
||||||
|
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||||
|
await tester.pumpWidget(
|
||||||
|
SxchngeApp(
|
||||||
|
localeProvider: LocaleProvider(initialLocale: Locale(language)),
|
||||||
|
verificationService: const DummyVerificationService(
|
||||||
|
delay: Duration.zero,
|
||||||
|
verifiedEmail: 'private-alias@example.com',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
find.byKey(const ValueKey('verification-verified')),
|
||||||
|
findsOneWidget,
|
||||||
|
);
|
||||||
|
expect(find.byKey(const ValueKey('email')), findsNothing);
|
||||||
|
final router = GoRouter.of(
|
||||||
|
tester.element(find.byType(VerificationPage)),
|
||||||
|
);
|
||||||
|
expect(router.routeInformationProvider.value.uri.path, '/verify-email');
|
||||||
|
await tester.ensureVisible(
|
||||||
|
find.byKey(const ValueKey('verification-continue')),
|
||||||
|
);
|
||||||
|
await tester.tap(find.byKey(const ValueKey('verification-continue')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
final email = tester.widget<TextFormField>(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'private-alias@example.com');
|
||||||
|
final password = tester.state<EditableTextState>(
|
||||||
|
find.descendant(
|
||||||
|
of: find.byKey(const ValueKey('password')),
|
||||||
|
matching: find.byType(EditableText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(password.currentTextEditingValue.text, isEmpty);
|
||||||
|
expect(password.widget.focusNode.hasFocus, isTrue);
|
||||||
|
expect(router.routeInformationProvider.value.uri.toString(), '/');
|
||||||
|
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||||
|
router.routerDelegate.currentConfiguration,
|
||||||
|
)!;
|
||||||
|
expect(
|
||||||
|
restored.state.toString(),
|
||||||
|
isNot(contains('private-alias@example.com')),
|
||||||
|
);
|
||||||
|
expect(restored.state.toString(), isNot(contains('demo-valid')));
|
||||||
|
await tester.enterText(
|
||||||
|
find.byKey(const ValueKey('email')),
|
||||||
|
'changed@example.com',
|
||||||
|
);
|
||||||
|
expect(email.controller!.text, 'changed@example.com');
|
||||||
|
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
testWidgets('unknown deep link redirects to canonical login URL', (
|
testWidgets('unknown deep link redirects to canonical login URL', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) async {
|
||||||
|
|||||||
@@ -148,13 +148,38 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
isNot(
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||||
isEmpty,
|
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 t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||||
await tapFactor(t, 'two-factor-confirm');
|
await tapFactor(t, 'two-factor-confirm');
|
||||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||||
|
expect(
|
||||||
|
t.testTextInput.log,
|
||||||
|
contains(
|
||||||
|
isMethodCall('TextInput.finishAutofillContext', arguments: true),
|
||||||
|
),
|
||||||
|
);
|
||||||
final login = analytics.events
|
final login = analytics.events
|
||||||
.where((e) => e.kind == JourneyKind.login)
|
.where((e) => e.kind == JourneyKind.login)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -172,6 +197,20 @@ void main() {
|
|||||||
await signIn(t);
|
await signIn(t);
|
||||||
await tapFactor(t, 'two-factor-cancel');
|
await tapFactor(t, 'two-factor-cancel');
|
||||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
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(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||||
expect(
|
expect(
|
||||||
t
|
t
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ class BackendDTO {
|
|||||||
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
(jsonDecode(body) as Map<String, dynamic>)['data'] as Map<String, dynamic>,
|
||||||
);
|
);
|
||||||
final Map<String, dynamic> data;
|
final Map<String, dynamic> data;
|
||||||
|
bool get verified => data['verified'] == true;
|
||||||
|
String? get email => data['email'] as String?;
|
||||||
String get error => data['error'] as String? ?? 'unavailable';
|
String get error => data['error'] as String? ?? 'unavailable';
|
||||||
String get userId =>
|
String get userId =>
|
||||||
data['userId'] as String? ??
|
data['userId'] as String? ??
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class AuthDTO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationDTO {
|
class VerificationDTO {
|
||||||
const VerificationDTO({required this.simulated});
|
const VerificationDTO({required this.simulated, this.email});
|
||||||
final bool simulated;
|
final bool simulated;
|
||||||
|
final String? email;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,13 @@ import '../models/two_factor.dart';
|
|||||||
import '../models/verification.dart';
|
import '../models/verification.dart';
|
||||||
|
|
||||||
abstract final class BackendMapper {
|
abstract final class BackendMapper {
|
||||||
|
static VerificationResult activation(BackendDTO dto) {
|
||||||
|
if (!dto.verified) {
|
||||||
|
throw const VerificationException(VerificationError.internal);
|
||||||
|
}
|
||||||
|
return VerificationResult(isSimulated: false, verifiedEmail: dto.email);
|
||||||
|
}
|
||||||
|
|
||||||
static AuthResult signup(BackendDTO dto) {
|
static AuthResult signup(BackendDTO dto) {
|
||||||
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
if (dto.userId.isEmpty) throw const FormatException('Missing user');
|
||||||
return AuthResult(userId: dto.userId, isSimulated: false);
|
return AuthResult(userId: dto.userId, isSimulated: false);
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ abstract final class AuthMapper {
|
|||||||
|
|
||||||
abstract final class VerificationMapper {
|
abstract final class VerificationMapper {
|
||||||
static VerificationResult fromDTO(VerificationDTO dto) =>
|
static VerificationResult fromDTO(VerificationDTO dto) =>
|
||||||
VerificationResult(isSimulated: dto.simulated);
|
VerificationResult(isSimulated: dto.simulated, verifiedEmail: dto.email);
|
||||||
}
|
}
|
||||||
@@ -17,15 +17,22 @@ class VerificationException implements Exception {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class VerificationResult {
|
class VerificationResult {
|
||||||
const VerificationResult({required this.isSimulated});
|
const VerificationResult({required this.isSimulated, this.verifiedEmail});
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final String? verifiedEmail;
|
||||||
}
|
}
|
||||||
|
|
||||||
class VerificationState {
|
class VerificationState {
|
||||||
const VerificationState(this.status, {this.isSimulated = false, this.error});
|
const VerificationState(
|
||||||
|
this.status, {
|
||||||
|
this.isSimulated = false,
|
||||||
|
this.error,
|
||||||
|
this.result,
|
||||||
|
});
|
||||||
final VerificationStatus status;
|
final VerificationStatus status;
|
||||||
final VerificationError? error;
|
final VerificationError? error;
|
||||||
final bool isSimulated;
|
final bool isSimulated;
|
||||||
|
final VerificationResult? result;
|
||||||
bool get isLoading =>
|
bool get isLoading =>
|
||||||
status == VerificationStatus.sending ||
|
status == VerificationStatus.sending ||
|
||||||
status == VerificationStatus.checking;
|
status == VerificationStatus.checking;
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ class VerificationProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
final result = await operation();
|
final result = await operation();
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
_state = VerificationState(success, isSimulated: result.isSimulated);
|
_state = VerificationState(
|
||||||
|
success,
|
||||||
|
isSimulated: result.isSimulated,
|
||||||
|
result: success == VerificationStatus.verified ? result : null,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (_disposed || generation != _generation) return;
|
if (_disposed || generation != _generation) return;
|
||||||
final code = error is VerificationException
|
final code = error is VerificationException
|
||||||
|
|||||||
@@ -172,8 +172,10 @@ class BackendAuthServices
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> confirm({required String token}) async {
|
Future<VerificationResult> confirm({required String token}) async {
|
||||||
await _post('verification/confirm', {'token': token}, verification: true);
|
final dto = await _post('verification/confirm', {
|
||||||
return VerificationMapper.fromDTO(const VerificationDTO(simulated: false));
|
'token': token,
|
||||||
|
}, verification: true);
|
||||||
|
return BackendMapper.activation(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
const DummyVerificationService({
|
const DummyVerificationService({
|
||||||
this.delay = const Duration(milliseconds: 600),
|
this.delay = const Duration(milliseconds: 600),
|
||||||
this.sendFailure,
|
this.sendFailure,
|
||||||
|
this.verifiedEmail = 'alex@example.com',
|
||||||
});
|
});
|
||||||
@override
|
@override
|
||||||
bool get isSimulated => true;
|
bool get isSimulated => true;
|
||||||
final Duration delay;
|
final Duration delay;
|
||||||
final VerificationError? sendFailure;
|
final VerificationError? sendFailure;
|
||||||
|
final String verifiedEmail;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<VerificationResult> send({
|
Future<VerificationResult> send({
|
||||||
@@ -41,7 +43,7 @@ class DummyVerificationService implements VerificationService, SimulationInfo {
|
|||||||
switch (token) {
|
switch (token) {
|
||||||
case 'demo-valid':
|
case 'demo-valid':
|
||||||
return VerificationMapper.fromDTO(
|
return VerificationMapper.fromDTO(
|
||||||
const VerificationDTO(simulated: true),
|
VerificationDTO(simulated: true, email: verifiedEmail),
|
||||||
);
|
);
|
||||||
case 'demo-expired':
|
case 'demo-expired':
|
||||||
throw const VerificationException(VerificationError.expired);
|
throw const VerificationException(VerificationError.expired);
|
||||||
|
|||||||
@@ -10,6 +10,48 @@ import 'package:sxchnge_auth/sxchnge_auth.dart';
|
|||||||
void main() {
|
void main() {
|
||||||
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
http.Response response(Map<String, Object?> data, [int status = 202]) =>
|
||||||
http.Response(jsonEncode({'data': data}), status);
|
http.Response(jsonEncode({'data': data}), status);
|
||||||
|
test(
|
||||||
|
'activation maps only a confirmed response to the verified email',
|
||||||
|
() async {
|
||||||
|
var confirmed = true;
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((request) async {
|
||||||
|
expect(request.url.path, '/api/v1/accounts/verification/confirm');
|
||||||
|
expect(jsonDecode(request.body), {'token': 'opaque-link'});
|
||||||
|
return response({
|
||||||
|
'verified': confirmed,
|
||||||
|
'email': 'alias@example.com',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
final accepted = await service.confirm(token: 'opaque-link');
|
||||||
|
expect(accepted.verifiedEmail, 'alias@example.com');
|
||||||
|
expect(accepted.isSimulated, isFalse);
|
||||||
|
confirmed = false;
|
||||||
|
await expectLater(
|
||||||
|
service.confirm(token: 'opaque-link'),
|
||||||
|
throwsA(isA<VerificationException>()),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
test(
|
||||||
|
'activation remains compatible with a verified response without email',
|
||||||
|
() async {
|
||||||
|
final service = BackendAuthServices(
|
||||||
|
baseUri: Uri.parse('https://api.example/api/v1'),
|
||||||
|
clientId: 'web',
|
||||||
|
client: MockClient((_) async => response({'verified': true})),
|
||||||
|
);
|
||||||
|
addTearDown(service.close);
|
||||||
|
expect(
|
||||||
|
(await service.confirm(token: 'opaque-link')).verifiedEmail,
|
||||||
|
isNull,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
test(
|
test(
|
||||||
'signup distinguishes support recovery and keeps the retry key',
|
'signup distinguishes support recovery and keeps the retry key',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ void main() {
|
|||||||
addTearDown(provider.dispose);
|
addTearDown(provider.dispose);
|
||||||
await provider.confirm(scenario.key);
|
await provider.confirm(scenario.key);
|
||||||
expect(provider.state.status, scenario.value);
|
expect(provider.state.status, scenario.value);
|
||||||
|
expect(
|
||||||
|
provider.state.result?.verifiedEmail,
|
||||||
|
scenario.value == VerificationStatus.verified
|
||||||
|
? 'alex@example.com'
|
||||||
|
: null,
|
||||||
|
);
|
||||||
expect(
|
expect(
|
||||||
provider.state.isSimulated,
|
provider.state.isSimulated,
|
||||||
scenario.value == VerificationStatus.verified,
|
scenario.value == VerificationStatus.verified,
|
||||||
@@ -75,7 +81,23 @@ void main() {
|
|||||||
service.pending.complete(const VerificationResult(isSimulated: true));
|
service.pending.complete(const VerificationResult(isSimulated: true));
|
||||||
await operation;
|
await operation;
|
||||||
expect(provider.state.status, VerificationStatus.idle);
|
expect(provider.state.status, VerificationStatus.idle);
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
});
|
});
|
||||||
|
test(
|
||||||
|
'a later failed confirmation clears the previous verified identity',
|
||||||
|
() async {
|
||||||
|
final provider = VerificationProvider(
|
||||||
|
const DummyVerificationService(delay: Duration.zero),
|
||||||
|
);
|
||||||
|
addTearDown(provider.dispose);
|
||||||
|
await provider.confirm('demo-valid');
|
||||||
|
expect(provider.state.result?.verifiedEmail, 'alex@example.com');
|
||||||
|
final operation = provider.confirm('bad');
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
await operation;
|
||||||
|
expect(provider.state.result, isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
test('dispose discards late exception', () async {
|
test('dispose discards late exception', () async {
|
||||||
final service = PendingService();
|
final service = PendingService();
|
||||||
final provider = VerificationProvider(service);
|
final provider = VerificationProvider(service);
|
||||||
|
|||||||
Reference in new issue
Block a user