Merge pull request 'Add reusable authentication and email verification services' (#8) from auth-4 into main

This commit was merged in pull request #8.
This commit is contained in:
tech committed 2026-09-11 13:45:39 +00:00
commit 7015ab137b
15 files changed
+708

No files matched your search

@@ -0,0 +1,3 @@
.dart_tool/
build/
pubspec.lock
+63
View File
@@ -0,0 +1,63 @@
# sxchnge_auth
Shared Flutter authentication providers and service contracts for web and mobile. Private package, version 0.1.0; Flutter 3.47+ / Dart 3.13+.
## Install and use
From `frontend/app` (or a sibling `frontend/mobile` app):
```yaml
dependencies:
sxchnge_auth:
path: ../packages/sxchnge_auth
```
```dart
import 'package:sxchnge_auth/sxchnge_auth.dart';
final login = LoginProvider(const DummyLoginService());
login.addListener(() {
// Render login.state.status, result, or typed error in your own UI.
});
await login.login(
email: 'demo@example.com',
password: 'example-password',
locale: 'en',
);
login.dispose();
```
`SignupProvider` accepts a `SignupService` and exposes `signup(name:, email:, password:, locale:)`. Both providers extend ChangeNotifier and can be installed with `ChangeNotifierProvider`, ListenableBuilder, or another Flutter state binding. The package itself depends only on Flutter foundation; it contains no widgets, routes, localization, web-only code, network client, or storage.
## Lifecycle and services
Providers expose immutable `AuthState`: idle, loading, success, or error. They trim email/name, preserve the password verbatim, pass the locale to services, suppress duplicate submissions while loading, and clear old errors before retrying. Reset invalidates older responses and allows a new request; disposal ignores all later completions. Reset does not cancel remote work. Do not treat a provider reset as undoing a production signup.
Services return `AuthResult` or throw `AuthException` with a typed `AuthError`. Unexpected exceptions are reduced to `unexpected`; raw errors and credentials are not retained in provider state. Credentials exist only as ephemeral request input. Localize error codes in each consuming application.
`DummyLoginService` and `DummySignupService` default to 600ms latency and simulated success. They do not validate credentials against an account registry, create accounts, issue tokens, open sessions, send requests, or persist data. Configure errors explicitly:
```dart
final service = DummyLoginService(
delay: Duration.zero,
failure: AuthError.invalidCredentials,
);
```
Supported failure codes: `invalidCredentials`, `emailInUse`, `unavailable`, and `unexpected`. Inject another implementation for deterministic retry scenarios or production integration. `AuthResult.isSimulated` distinguishes dummy responses; a successful operation alone must never be treated as an authorization decision. The package does not implement authenticated application/session state yet.
## Sendico reuse
Adapted the ChangeNotifier lifecycle, service separation, locale-carrying request, and typed-outcome patterns from Sendico's `pshared/lib/provider/account.dart`, `models/auth/login_outcome.dart`, and `service/authorization/client.dart`. These were patterns rather than directly portable implementations: the originals depend on Sendico account/organization models, device IDs, static API services, token storage, and verification flows. Those dependencies are deliberately absent here. The B2B organization signup contract is not copied into the consumer product.
When a supported sxchnge BFF contract exists, implement transport DTOs and mappers behind the service interfaces, returning domain results. No dummy DTO/mapping layer is needed without serialized transport. Keep web cookie and mobile credential handling in production platform adapters, not the providers.
## Validation
`flutter analyze` and `flutter test` from this directory; `make test-frontend` from the repository root also runs all consuming app and theme tests. Tests cover duplicate submission, request forwarding, typed/unexpected failures, retry, reset races, disposal, and both dummy implementations.
## Email verification
`VerificationService` exposes `send(email, locale)` and `confirm(token)` as named-argument methods. Inject an implementation into `VerificationProvider`, shared by web and mobile. The provider exposes immutable `VerificationState` (idle/sending/sent/checking/verified/expired/invalid/error), suppresses concurrent duplicate operations, and ignores completions after reset or disposal. Typed `VerificationException` maps confirmation failures; unknown exceptions become internal errors without exposing exception text. Sending failures always become the general error state.
`DummyVerificationService` supports `demo-valid`, `demo-expired`, and `demo-internal` confirmation tokens; other tokens are invalid. Configure `delay` and `sendFailure` for tests. Results are explicitly simulated. It performs no network, storage, mail delivery, or account mutation. A production adapter must implement backend validation, delivery, rate limits, and safe token handling; this client provider is not an authorization boundary.
@@ -0,0 +1,56 @@
/// Ephemeral input: never persist, log, or include passwords in provider state.
class LoginRequest {
const LoginRequest({
required this.email,
required this.password,
required this.locale,
});
final String email;
final String password;
final String locale;
}
class SignupRequest {
const SignupRequest({
required this.name,
required this.email,
required this.password,
required this.locale,
});
final String name;
final String email;
final String password;
final String locale;
}
/// An operation result, not proof of a session or an authorization decision.
class AuthResult {
const AuthResult({required this.userId, required this.isSimulated});
final String userId;
final bool isSimulated;
}
enum AuthError { invalidCredentials, emailInUse, unavailable, unexpected }
class AuthException implements Exception {
const AuthException(this.code);
final AuthError code;
@override
String toString() => 'AuthException(${code.name})';
}
enum AuthStatus { idle, loading, success, error }
class AuthState {
const AuthState._(this.status, {this.result, this.error});
const AuthState.idle() : this._(AuthStatus.idle);
const AuthState.loading() : this._(AuthStatus.loading);
const AuthState.success(AuthResult result)
: this._(AuthStatus.success, result: result);
const AuthState.failure(AuthError error)
: this._(AuthStatus.error, error: error);
final AuthStatus status;
final AuthResult? result;
final AuthError? error;
bool get isLoading => status == AuthStatus.loading;
}
@@ -0,0 +1,31 @@
enum VerificationStatus {
idle,
sending,
sent,
checking,
verified,
expired,
invalid,
error,
}
enum VerificationError { expired, invalid, internal }
class VerificationException implements Exception {
const VerificationException(this.code);
final VerificationError code;
}
class VerificationResult {
const VerificationResult({required this.isSimulated});
final bool isSimulated;
}
class VerificationState {
const VerificationState(this.status, {this.isSimulated = false});
final VerificationStatus status;
final bool isSimulated;
bool get isLoading =>
status == VerificationStatus.sending ||
status == VerificationStatus.checking;
}
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import '../models/auth.dart';
/// Shared operation lifecycle adapted from Sendico's ChangeNotifier providers.
/// Reset invalidates pending results; it does not cancel service-side work.
abstract class AuthProvider extends ChangeNotifier {
AuthState _state = const AuthState.idle();
int _generation = 0;
bool _disposed = false;
AuthState get state => _state;
@protected
Future<void> run(Future<AuthResult> Function() operation) async {
if (_disposed || _state.isLoading) return;
final generation = ++_generation;
_state = const AuthState.loading();
notifyListeners();
try {
final result = await operation();
if (_disposed || generation != _generation) return;
_state = AuthState.success(result);
} catch (error) {
if (_disposed || generation != _generation) return;
// Never retain or expose arbitrary exceptions that may contain credentials.
_state = AuthState.failure(
error is AuthException ? error.code : AuthError.unexpected,
);
}
if (!_disposed && generation == _generation) notifyListeners();
}
void reset() {
if (_disposed) return;
++_generation;
_state = const AuthState.idle();
notifyListeners();
}
@override
void dispose() {
_disposed = true;
++_generation;
super.dispose();
}
}
@@ -0,0 +1,18 @@
import '../models/auth.dart';
import '../services/contracts.dart';
import 'auth.dart';
class LoginProvider extends AuthProvider {
LoginProvider(this.service);
final LoginService service;
Future<void> login({
required String email,
required String password,
required String locale,
}) => run(
() => service.login(
LoginRequest(email: email.trim(), password: password, locale: locale),
),
);
}
@@ -0,0 +1,24 @@
import '../models/auth.dart';
import '../services/contracts.dart';
import 'auth.dart';
class SignupProvider extends AuthProvider {
SignupProvider(this.service);
final SignupService service;
Future<void> signup({
required String name,
required String email,
required String password,
required String locale,
}) => run(
() => service.signup(
SignupRequest(
name: name.trim(),
email: email.trim(),
password: password,
locale: locale,
),
),
);
}
@@ -0,0 +1,73 @@
import 'package:flutter/foundation.dart';
import '../models/verification.dart';
import '../services/verification.dart';
class VerificationProvider extends ChangeNotifier {
VerificationProvider(this.service);
final VerificationService service;
VerificationState _state = const VerificationState(VerificationStatus.idle);
VerificationState get state => _state;
bool _disposed = false;
int _generation = 0;
Future<void> send({required String email, required String locale}) => _run(
() => service.send(email: email.trim(), locale: locale),
VerificationStatus.sending,
VerificationStatus.sent,
);
Future<void> confirm(String token) => _run(
() => token.trim().isEmpty
? Future.error(const VerificationException(VerificationError.invalid))
: service.confirm(token: token),
VerificationStatus.checking,
VerificationStatus.verified,
);
Future<void> _run(
Future<VerificationResult> Function() operation,
VerificationStatus loading,
VerificationStatus success,
) async {
if (_disposed || _state.isLoading) return;
final generation = ++_generation;
_state = VerificationState(loading);
notifyListeners();
if (_disposed || generation != _generation) return;
try {
final result = await operation();
if (_disposed || generation != _generation) return;
_state = VerificationState(success, isSimulated: result.isSimulated);
} catch (error) {
if (_disposed || generation != _generation) return;
final code = error is VerificationException
? error.code
: VerificationError.internal;
// Send failures must not be presented as confirmation-token failures.
_state = VerificationState(
loading == VerificationStatus.sending
? VerificationStatus.error
: switch (code) {
VerificationError.expired => VerificationStatus.expired,
VerificationError.invalid => VerificationStatus.invalid,
VerificationError.internal => VerificationStatus.error,
},
);
}
notifyListeners();
}
void reset() {
if (_disposed) return;
++_generation;
_state = const VerificationState(VerificationStatus.idle);
notifyListeners();
}
@override
void dispose() {
_disposed = true;
++_generation;
super.dispose();
}
}
@@ -0,0 +1,11 @@
import '../models/auth.dart';
/// Production adapters own transport, DTO mapping, and authentication effects.
/// Providers and widgets only consume domain results and typed failures.
abstract interface class LoginService {
Future<AuthResult> login(LoginRequest request);
}
abstract interface class SignupService {
Future<AuthResult> signup(SignupRequest request);
}
@@ -0,0 +1,36 @@
import '../models/auth.dart';
import 'contracts.dart';
/// Deterministic local simulation. No network, storage, credentials or sessions
/// are retained. Configure failures explicitly rather than using magic emails.
class DummyLoginService implements LoginService {
const DummyLoginService({
this.delay = const Duration(milliseconds: 600),
this.failure,
});
final Duration delay;
final AuthError? failure;
@override
Future<AuthResult> login(LoginRequest request) async {
await Future<void>.delayed(delay);
if (failure case final error?) throw AuthException(error);
return const AuthResult(userId: 'demo-user', isSimulated: true);
}
}
class DummySignupService implements SignupService {
const DummySignupService({
this.delay = const Duration(milliseconds: 600),
this.failure,
});
final Duration delay;
final AuthError? failure;
@override
Future<AuthResult> signup(SignupRequest request) async {
await Future<void>.delayed(delay);
if (failure case final error?) throw AuthException(error);
return const AuthResult(userId: 'demo-user', isSimulated: true);
}
}
@@ -0,0 +1,44 @@
import '../models/verification.dart';
abstract interface class VerificationService {
Future<VerificationResult> send({
required String email,
required String locale,
});
Future<VerificationResult> confirm({required String token});
}
/// Explicit preview tokens only. Never use this implementation in production.
class DummyVerificationService implements VerificationService {
const DummyVerificationService({
this.delay = const Duration(milliseconds: 600),
this.sendFailure,
});
final Duration delay;
final VerificationError? sendFailure;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
await Future<void>.delayed(delay);
if (sendFailure case final error?) throw VerificationException(error);
return const VerificationResult(isSimulated: true);
}
@override
Future<VerificationResult> confirm({required String token}) async {
await Future<void>.delayed(delay);
switch (token) {
case 'demo-valid':
return const VerificationResult(isSimulated: true);
case 'demo-expired':
throw const VerificationException(VerificationError.expired);
case 'demo-internal':
throw const VerificationException(VerificationError.internal);
default:
throw const VerificationException(VerificationError.invalid);
}
}
}
@@ -0,0 +1,12 @@
/// Platform-independent authentication state and injectable services.
library;
export 'src/models/auth.dart';
export 'src/providers/auth.dart';
export 'src/providers/login.dart';
export 'src/providers/signup.dart';
export 'src/services/contracts.dart';
export 'src/services/dummy.dart';
export 'src/models/verification.dart';
export 'src/providers/verification.dart';
export 'src/services/verification.dart';
@@ -0,0 +1,13 @@
name: sxchnge_auth
description: Shared authentication providers and service contracts for SXCHNGE Flutter clients.
version: 0.1.0
publish_to: none
environment:
sdk: '>=3.13.0 <4.0.0'
flutter: '>=3.47.0'
dependencies:
flutter:
sdk: flutter
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,177 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
class ControlledService implements LoginService, SignupService {
final calls = <Completer<AuthResult>>[];
LoginRequest? loginInput;
SignupRequest? signupInput;
Future<AuthResult> next() {
final call = Completer<AuthResult>();
calls.add(call);
return call.future;
}
@override
Future<AuthResult> login(LoginRequest request) {
loginInput = request;
return next();
}
@override
Future<AuthResult> signup(SignupRequest request) {
signupInput = request;
return next();
}
}
void main() {
const result = AuthResult(userId: 'demo', isSimulated: true);
for (final signup in [false, true]) {
group(signup ? 'signup' : 'login', () {
late ControlledService service;
late AuthProvider provider;
Future<void> submit() => signup
? (provider as SignupProvider).signup(
name: ' Alex ',
email: ' a@example.com ',
password: ' secret ',
locale: 'ru',
)
: (provider as LoginProvider).login(
email: ' a@example.com ',
password: ' secret ',
locale: 'ru',
);
setUp(() {
service = ControlledService();
provider = signup ? SignupProvider(service) : LoginProvider(service);
});
test(
'loading, duplicate suppression, result and normalized input',
() async {
final states = <AuthStatus>[];
provider.addListener(() => states.add(provider.state.status));
final first = submit();
await submit();
expect(service.calls, hasLength(1));
expect(provider.state.isLoading, isTrue);
if (signup) {
expect(service.signupInput!.name, 'Alex');
expect(service.signupInput!.email, 'a@example.com');
expect(service.signupInput!.password, ' secret ');
expect(service.signupInput!.locale, 'ru');
} else {
expect(service.loginInput!.email, 'a@example.com');
expect(service.loginInput!.password, ' secret ');
expect(service.loginInput!.locale, 'ru');
}
service.calls.single.complete(result);
await first;
expect(states, [AuthStatus.loading, AuthStatus.success]);
expect(provider.state.result, same(result));
provider.dispose();
},
);
test('typed failure and retry clear previous error', () async {
final first = submit();
service.calls.last.completeError(
const AuthException(AuthError.unavailable),
);
await first;
expect(provider.state.error, AuthError.unavailable);
final retry = submit();
expect(provider.state.error, isNull);
service.calls.last.complete(result);
await retry;
expect(provider.state.status, AuthStatus.success);
provider.dispose();
});
test('unknown exceptions are sanitized', () async {
final first = submit();
service.calls.last.completeError(StateError('secret password'));
await first;
expect(provider.state.error, AuthError.unexpected);
expect(provider.state.result, isNull);
provider.dispose();
});
test('reset rejects stale completion during a new operation', () async {
final first = submit();
provider.reset();
expect(provider.state.status, AuthStatus.idle);
final second = submit();
service.calls.first.complete(result);
await first;
expect(provider.state.status, AuthStatus.loading);
service.calls.last.complete(result);
await second;
expect(provider.state.status, AuthStatus.success);
provider.dispose();
});
test('dispose ignores late failures and further submissions', () async {
var notifications = 0;
provider.addListener(() => notifications++);
final pending = submit();
provider.dispose();
service.calls.last.completeError(
const AuthException(AuthError.unavailable),
);
await pending;
await submit();
expect(notifications, 1);
expect(service.calls, hasLength(1));
});
});
}
test('dummy services simulate success and configured errors', () async {
const login = LoginRequest(
email: 'a@example.com',
password: 'secret',
locale: 'en',
);
const signup = SignupRequest(
name: 'Alex',
email: 'a@example.com',
password: 'secret',
locale: 'ru',
);
expect(
(await const DummyLoginService(delay: Duration.zero).login(login))
.isSimulated,
isTrue,
);
expect(
(await const DummySignupService(delay: Duration.zero).signup(signup))
.isSimulated,
isTrue,
);
await expectLater(
const DummyLoginService(
delay: Duration.zero,
failure: AuthError.invalidCredentials,
).login(login),
throwsA(
isA<AuthException>().having(
(e) => e.code,
'code',
AuthError.invalidCredentials,
),
),
);
await expectLater(
const DummySignupService(
delay: Duration.zero,
failure: AuthError.emailInUse,
).signup(signup),
throwsA(
isA<AuthException>().having(
(e) => e.code,
'code',
AuthError.emailInUse,
),
),
);
});
}
@@ -0,0 +1,101 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
class PendingService implements VerificationService {
final pending = Completer<VerificationResult>();
int calls = 0;
String? email;
String? locale;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) {
calls++;
this.email = email;
this.locale = locale;
return pending.future;
}
@override
Future<VerificationResult> confirm({required String token}) {
calls++;
return pending.future;
}
}
void main() {
for (final scenario in {
'demo-valid': VerificationStatus.verified,
'demo-expired': VerificationStatus.expired,
'bad': VerificationStatus.invalid,
'': VerificationStatus.invalid,
'demo-internal': VerificationStatus.error,
}.entries) {
test('confirmation maps ${scenario.key} to ${scenario.value}', () async {
final provider = VerificationProvider(
const DummyVerificationService(delay: Duration.zero),
);
addTearDown(provider.dispose);
await provider.confirm(scenario.key);
expect(provider.state.status, scenario.value);
expect(
provider.state.isSimulated,
scenario.value == VerificationStatus.verified,
);
});
}
test(
'send trims email, forwards locale and suppresses duplicate requests',
() async {
final service = PendingService();
final provider = VerificationProvider(service);
addTearDown(provider.dispose);
final first = provider.send(email: ' alex@example.com ', locale: 'ru');
expect(provider.state.status, VerificationStatus.sending);
await provider.send(email: 'other@example.com', locale: 'en');
expect(service.calls, 1);
expect(service.email, 'alex@example.com');
expect(service.locale, 'ru');
service.pending.complete(const VerificationResult(isSimulated: true));
await first;
expect(provider.state.status, VerificationStatus.sent);
expect(provider.state.isSimulated, isTrue);
},
);
test('reset discards late result', () async {
final service = PendingService();
final provider = VerificationProvider(service);
addTearDown(provider.dispose);
final operation = provider.confirm('token');
provider.reset();
service.pending.complete(const VerificationResult(isSimulated: true));
await operation;
expect(provider.state.status, VerificationStatus.idle);
});
test('dispose discards late exception', () async {
final service = PendingService();
final provider = VerificationProvider(service);
final operation = provider.confirm('token');
provider.dispose();
service.pending.completeError(StateError('private error'));
await operation;
});
test(
'send errors are internal errors even for token exception types',
() async {
final provider = VerificationProvider(
const DummyVerificationService(
delay: Duration.zero,
sendFailure: VerificationError.expired,
),
);
addTearDown(provider.dispose);
await provider.send(email: 'alex@example.com', locale: 'en');
expect(provider.state.status, VerificationStatus.error);
},
);
}