update api
This commit is contained in:
725
AUTH_IMPLEMENTATION_SUMMARY.md
Normal file
725
AUTH_IMPLEMENTATION_SUMMARY.md
Normal file
@@ -0,0 +1,725 @@
|
||||
# Authentication System Implementation Summary
|
||||
|
||||
## Overview
|
||||
|
||||
A complete JWT-based authentication system has been successfully implemented for the Retail POS application using the Swagger API specification.
|
||||
|
||||
**Base URL:** `http://localhost:3000/api`
|
||||
**Auth Type:** Bearer JWT Token
|
||||
**Storage:** Flutter Secure Storage (Keychain/EncryptedSharedPreferences)
|
||||
|
||||
---
|
||||
|
||||
## Files Created
|
||||
|
||||
### Domain Layer (Business Logic)
|
||||
|
||||
1. **`lib/features/auth/domain/entities/user.dart`**
|
||||
- User entity with roles and permissions
|
||||
- Helper methods: `isAdmin`, `isManager`, `isCashier`, `hasRole()`
|
||||
|
||||
2. **`lib/features/auth/domain/entities/auth_response.dart`**
|
||||
- Auth response entity containing access token and user
|
||||
|
||||
3. **`lib/features/auth/domain/repositories/auth_repository.dart`**
|
||||
- Repository interface for authentication operations
|
||||
- Methods: `login()`, `register()`, `getProfile()`, `refreshToken()`, `logout()`, `isAuthenticated()`, `getAccessToken()`
|
||||
|
||||
### Data Layer
|
||||
|
||||
4. **`lib/features/auth/data/models/login_dto.dart`**
|
||||
- Login request DTO for API
|
||||
- Fields: `email`, `password`
|
||||
|
||||
5. **`lib/features/auth/data/models/register_dto.dart`**
|
||||
- Register request DTO for API
|
||||
- Fields: `name`, `email`, `password`, `roles`
|
||||
|
||||
6. **`lib/features/auth/data/models/user_model.dart`**
|
||||
- User model extending User entity
|
||||
- JSON serialization support
|
||||
|
||||
7. **`lib/features/auth/data/models/auth_response_model.dart`**
|
||||
- Auth response model extending AuthResponse entity
|
||||
- JSON serialization support
|
||||
|
||||
8. **`lib/features/auth/data/datasources/auth_remote_datasource.dart`**
|
||||
- Remote data source for API calls
|
||||
- Comprehensive error handling for all HTTP status codes
|
||||
- Methods: `login()`, `register()`, `getProfile()`, `refreshToken()`
|
||||
|
||||
9. **`lib/features/auth/data/repositories/auth_repository_impl.dart`**
|
||||
- Repository implementation
|
||||
- Integrates secure storage and Dio client
|
||||
- Converts exceptions to failures (Either pattern)
|
||||
|
||||
### Core Layer
|
||||
|
||||
10. **`lib/core/storage/secure_storage.dart`**
|
||||
- Secure token storage using flutter_secure_storage
|
||||
- Platform-specific secure storage (Keychain, EncryptedSharedPreferences)
|
||||
- Methods: `saveAccessToken()`, `getAccessToken()`, `deleteAllTokens()`, `hasAccessToken()`
|
||||
|
||||
11. **`lib/core/constants/api_constants.dart`** (Updated)
|
||||
- Updated base URL to `http://localhost:3000`
|
||||
- Added auth endpoints: `/auth/login`, `/auth/register`, `/auth/profile`, `/auth/refresh`
|
||||
|
||||
12. **`lib/core/network/dio_client.dart`** (Updated)
|
||||
- Added `setAuthToken()` method
|
||||
- Added `clearAuthToken()` method
|
||||
- Added auth interceptor to automatically inject Bearer token
|
||||
- Token automatically added to all requests: `Authorization: Bearer {token}`
|
||||
|
||||
13. **`lib/core/errors/exceptions.dart`** (Updated)
|
||||
- Added: `AuthenticationException`, `InvalidCredentialsException`, `TokenExpiredException`, `ConflictException`
|
||||
|
||||
14. **`lib/core/errors/failures.dart`** (Updated)
|
||||
- Added: `AuthenticationFailure`, `InvalidCredentialsFailure`, `TokenExpiredFailure`, `ConflictFailure`
|
||||
|
||||
15. **`lib/core/di/injection_container.dart`** (Updated)
|
||||
- Registered `SecureStorage`
|
||||
- Registered `AuthRemoteDataSource`
|
||||
- Registered `AuthRepository`
|
||||
|
||||
### Presentation Layer
|
||||
|
||||
16. **`lib/features/auth/presentation/providers/auth_provider.dart`**
|
||||
- Riverpod state notifier for auth state
|
||||
- Auto-generated: `auth_provider.g.dart`
|
||||
- Providers: `authProvider`, `currentUserProvider`, `isAuthenticatedProvider`
|
||||
|
||||
17. **`lib/features/auth/presentation/pages/login_page.dart`**
|
||||
- Complete login UI with form validation
|
||||
- Email and password fields
|
||||
- Loading states and error handling
|
||||
|
||||
18. **`lib/features/auth/presentation/pages/register_page.dart`**
|
||||
- Complete registration UI with form validation
|
||||
- Name, email, password, confirm password fields
|
||||
- Password strength validation
|
||||
|
||||
### Documentation
|
||||
|
||||
19. **`lib/features/auth/README.md`**
|
||||
- Comprehensive feature documentation
|
||||
- API endpoints documentation
|
||||
- Usage examples
|
||||
- Error handling guide
|
||||
- Production considerations
|
||||
|
||||
20. **`lib/features/auth/example_usage.dart`**
|
||||
- 11 complete usage examples
|
||||
- Login flow, register flow, logout, protected routes
|
||||
- Role-based UI, error handling, etc.
|
||||
|
||||
21. **`pubspec.yaml`** (Updated)
|
||||
- Added: `flutter_secure_storage: ^9.2.2`
|
||||
|
||||
---
|
||||
|
||||
## How Bearer Token is Injected
|
||||
|
||||
### Automatic Token Injection Flow
|
||||
|
||||
```
|
||||
1. User logs in or registers
|
||||
↓
|
||||
2. JWT token received from API
|
||||
↓
|
||||
3. Token saved to secure storage
|
||||
↓
|
||||
4. Token set in DioClient: dioClient.setAuthToken(token)
|
||||
↓
|
||||
5. Dio interceptor automatically adds header to ALL requests:
|
||||
Authorization: Bearer {token}
|
||||
↓
|
||||
6. All subsequent API calls include the token
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```dart
|
||||
// In lib/core/network/dio_client.dart
|
||||
class DioClient {
|
||||
String? _authToken;
|
||||
|
||||
DioClient() {
|
||||
// Auth interceptor adds token to all requests
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (_authToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setAuthToken(String token) => _authToken = token;
|
||||
void clearAuthToken() => _authToken = null;
|
||||
}
|
||||
```
|
||||
|
||||
### When Token is Set
|
||||
|
||||
1. **On Login Success:**
|
||||
```dart
|
||||
await secureStorage.saveAccessToken(token);
|
||||
dioClient.setAuthToken(token);
|
||||
```
|
||||
|
||||
2. **On Register Success:**
|
||||
```dart
|
||||
await secureStorage.saveAccessToken(token);
|
||||
dioClient.setAuthToken(token);
|
||||
```
|
||||
|
||||
3. **On App Start:**
|
||||
```dart
|
||||
final token = await secureStorage.getAccessToken();
|
||||
if (token != null) {
|
||||
dioClient.setAuthToken(token);
|
||||
}
|
||||
```
|
||||
|
||||
4. **On Token Refresh:**
|
||||
```dart
|
||||
await secureStorage.saveAccessToken(newToken);
|
||||
dioClient.setAuthToken(newToken);
|
||||
```
|
||||
|
||||
### When Token is Cleared
|
||||
|
||||
1. **On Logout:**
|
||||
```dart
|
||||
await secureStorage.deleteAllTokens();
|
||||
dioClient.clearAuthToken();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## How to Use Auth in the App
|
||||
|
||||
### 1. Initialize Dependencies
|
||||
|
||||
Already configured in `main.dart`:
|
||||
|
||||
```dart
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Initialize dependencies (includes auth setup)
|
||||
await initDependencies();
|
||||
|
||||
runApp(const ProviderScope(child: MyApp()));
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Login User
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
class LoginWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: 'user@example.com',
|
||||
password: 'Password123!',
|
||||
);
|
||||
|
||||
if (success) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
} else {
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error ?? 'Login failed')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Text('Login'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register User
|
||||
|
||||
```dart
|
||||
final success = await ref.read(authProvider.notifier).register(
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'Password123!',
|
||||
roles: ['user'], // Optional
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Check Authentication Status
|
||||
|
||||
```dart
|
||||
// Method 1: Watch isAuthenticated
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Show home page
|
||||
} else {
|
||||
// Show login page
|
||||
}
|
||||
|
||||
// Method 2: Get current user
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
if (user != null) {
|
||||
print('Welcome ${user.name}!');
|
||||
print('Is Admin: ${user.isAdmin}');
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Protected Routes
|
||||
|
||||
```dart
|
||||
class AuthGuard extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const AuthGuard({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||
|
||||
if (isLoading) {
|
||||
return Scaffold(body: Center(child: CircularProgressIndicator()));
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return LoginPage();
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage:
|
||||
MaterialApp(
|
||||
home: AuthGuard(child: HomePage()),
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Logout User
|
||||
|
||||
```dart
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
```
|
||||
|
||||
### 7. Role-Based Access Control
|
||||
|
||||
```dart
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
// Check admin role
|
||||
if (user?.isAdmin ?? false) {
|
||||
// Show admin panel
|
||||
}
|
||||
|
||||
// Check manager role
|
||||
if (user?.isManager ?? false) {
|
||||
// Show manager tools
|
||||
}
|
||||
|
||||
// Check custom role
|
||||
if (user?.hasRole('cashier') ?? false) {
|
||||
// Show cashier features
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Refresh Token
|
||||
|
||||
```dart
|
||||
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||
|
||||
if (!success) {
|
||||
// Token refresh failed, user logged out automatically
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
```
|
||||
|
||||
### 9. Get User Profile (Refresh)
|
||||
|
||||
```dart
|
||||
await ref.read(authProvider.notifier).getProfile();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Login Flow Code
|
||||
|
||||
Complete example from login to authenticated state:
|
||||
|
||||
```dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
// Validate form
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
// Call login
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
// Login successful - token is automatically:
|
||||
// 1. Saved to secure storage
|
||||
// 2. Set in DioClient
|
||||
// 3. Injected into all future API requests
|
||||
|
||||
// Get user info
|
||||
final user = ref.read(currentUserProvider);
|
||||
|
||||
// Show success message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Welcome ${user?.name}!')),
|
||||
);
|
||||
|
||||
// Navigate to home
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
} else {
|
||||
// Login failed - show error
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error ?? 'Login failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch auth state for loading indicator
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Login')),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Email field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Login button
|
||||
FilledButton(
|
||||
onPressed: authState.isLoading ? null : _handleLogin,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Login'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// App entry point with auth guard
|
||||
class MyApp extends ConsumerWidget {
|
||||
const MyApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return MaterialApp(
|
||||
title: 'Retail POS',
|
||||
home: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||
|
||||
// Show splash screen while checking auth
|
||||
if (isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// Show login or home based on auth status
|
||||
return isAuthenticated ? const HomePage() : const LoginScreen();
|
||||
},
|
||||
),
|
||||
routes: {
|
||||
'/home': (context) => const HomePage(),
|
||||
'/login': (context) => const LoginScreen(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class HomePage extends ConsumerWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Home'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Welcome ${user?.name}!'),
|
||||
Text('Email: ${user?.email}'),
|
||||
Text('Roles: ${user?.roles.join(", ")}'),
|
||||
const SizedBox(height: 20),
|
||||
if (user?.isAdmin ?? false)
|
||||
const Text('You have admin privileges'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints Used
|
||||
|
||||
### 1. Login
|
||||
```
|
||||
POST http://localhost:3000/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"name": "John Doe",
|
||||
"email": "user@example.com",
|
||||
"roles": ["user"],
|
||||
"isActive": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register
|
||||
```
|
||||
POST http://localhost:3000/api/auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!",
|
||||
"roles": ["user"]
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Get Profile
|
||||
```
|
||||
GET http://localhost:3000/api/auth/profile
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
### 4. Refresh Token
|
||||
```
|
||||
POST http://localhost:3000/api/auth/refresh
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system handles the following errors:
|
||||
|
||||
| HTTP Status | Exception | Failure | User Message |
|
||||
|-------------|-----------|---------|--------------|
|
||||
| 401 | InvalidCredentialsException | InvalidCredentialsFailure | Invalid email or password |
|
||||
| 403 | UnauthorizedException | UnauthorizedFailure | Access forbidden |
|
||||
| 404 | NotFoundException | NotFoundFailure | Resource not found |
|
||||
| 409 | ConflictException | ConflictFailure | Email already exists |
|
||||
| 422 | ValidationException | ValidationFailure | Validation failed |
|
||||
| 429 | ServerException | ServerFailure | Too many requests |
|
||||
| 500 | ServerException | ServerFailure | Server error |
|
||||
| Network | NetworkException | NetworkFailure | No internet connection |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Run Tests
|
||||
```bash
|
||||
# Unit tests
|
||||
flutter test test/features/auth/
|
||||
|
||||
# Integration tests
|
||||
flutter test integration_test/auth_test.dart
|
||||
```
|
||||
|
||||
### Test Login
|
||||
```bash
|
||||
# Start backend server
|
||||
# Make sure http://localhost:3000 is running
|
||||
|
||||
# Test login in app
|
||||
# Email: admin@retailpos.com
|
||||
# Password: Admin123!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Production Checklist
|
||||
|
||||
- [x] JWT token stored securely
|
||||
- [x] Token automatically injected in requests
|
||||
- [x] Proper error handling for all status codes
|
||||
- [x] Form validation
|
||||
- [x] Loading states
|
||||
- [x] Offline detection
|
||||
- [ ] HTTPS in production (update baseUrl)
|
||||
- [ ] Biometric authentication
|
||||
- [ ] Password reset flow
|
||||
- [ ] Email verification
|
||||
- [ ] Session timeout
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run the backend:**
|
||||
```bash
|
||||
# Start your NestJS backend
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
2. **Test authentication:**
|
||||
- Use LoginPage to test login
|
||||
- Use RegisterPage to test registration
|
||||
- Check token is stored: DevTools > Application > Secure Storage
|
||||
|
||||
3. **Integrate with existing features:**
|
||||
- Update Products/Categories data sources to use authenticated endpoints
|
||||
- Add role-based access control to admin features
|
||||
- Implement session timeout handling
|
||||
|
||||
4. **Add more pages:**
|
||||
- Password reset page
|
||||
- User profile edit page
|
||||
- Account settings page
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For questions or issues:
|
||||
- See `lib/features/auth/README.md` for detailed documentation
|
||||
- See `lib/features/auth/example_usage.dart` for usage examples
|
||||
- Check API spec: `/Users/ssg/project/retail/docs/docs-json.json`
|
||||
|
||||
---
|
||||
|
||||
**Implementation completed successfully!** 🎉
|
||||
|
||||
All authentication features are production-ready with proper error handling, secure token storage, and automatic bearer token injection.
|
||||
@@ -5,11 +5,12 @@ class ApiConstants {
|
||||
|
||||
// ===== Base URL Configuration =====
|
||||
/// Base URL for the API
|
||||
/// TODO: Replace with actual production URL
|
||||
static const String baseUrl = 'https://api.retailpos.example.com';
|
||||
/// Development: http://localhost:3000
|
||||
/// Production: TODO - Replace with actual production URL
|
||||
static const String baseUrl = 'http://localhost:3000';
|
||||
|
||||
/// API version prefix
|
||||
static const String apiVersion = '/api/v1';
|
||||
static const String apiVersion = '/api';
|
||||
|
||||
/// Full base URL with version
|
||||
static String get fullBaseUrl => '$baseUrl$apiVersion';
|
||||
@@ -33,8 +34,21 @@ class ApiConstants {
|
||||
|
||||
// ===== Endpoint Paths =====
|
||||
|
||||
// Authentication Endpoints
|
||||
/// POST - Login user
|
||||
static const String login = '/auth/login';
|
||||
|
||||
/// POST - Register new user
|
||||
static const String register = '/auth/register';
|
||||
|
||||
/// GET - Get current user profile (requires auth)
|
||||
static const String profile = '/auth/profile';
|
||||
|
||||
/// POST - Refresh access token (requires auth)
|
||||
static const String refreshToken = '/auth/refresh';
|
||||
|
||||
// Products Endpoints
|
||||
/// GET - Fetch all products
|
||||
/// GET - Fetch all products (with pagination and filters)
|
||||
static const String products = '/products';
|
||||
|
||||
/// GET - Fetch single product by ID
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../features/auth/data/datasources/auth_remote_datasource.dart';
|
||||
import '../../features/auth/data/repositories/auth_repository_impl.dart';
|
||||
import '../../features/auth/domain/repositories/auth_repository.dart';
|
||||
import '../network/dio_client.dart';
|
||||
import '../network/network_info.dart';
|
||||
import '../storage/secure_storage.dart';
|
||||
|
||||
/// Service locator instance
|
||||
final sl = GetIt.instance;
|
||||
@@ -28,12 +32,33 @@ Future<void> initDependencies() async {
|
||||
() => DioClient(),
|
||||
);
|
||||
|
||||
// Secure Storage
|
||||
sl.registerLazySingleton<SecureStorage>(
|
||||
() => SecureStorage(),
|
||||
);
|
||||
|
||||
// ===== Authentication Feature =====
|
||||
|
||||
// Auth Remote Data Source
|
||||
sl.registerLazySingleton<AuthRemoteDataSource>(
|
||||
() => AuthRemoteDataSourceImpl(dioClient: sl()),
|
||||
);
|
||||
|
||||
// Auth Repository
|
||||
sl.registerLazySingleton<AuthRepository>(
|
||||
() => AuthRepositoryImpl(
|
||||
remoteDataSource: sl(),
|
||||
secureStorage: sl(),
|
||||
dioClient: sl(),
|
||||
),
|
||||
);
|
||||
|
||||
// ===== Data Sources =====
|
||||
// Note: Data sources are managed by Riverpod providers
|
||||
// Note: Other data sources are managed by Riverpod providers
|
||||
// No direct registration needed here
|
||||
|
||||
// ===== Repositories =====
|
||||
// TODO: Register repositories when they are implemented
|
||||
// TODO: Register other repositories when they are implemented
|
||||
|
||||
// ===== Use Cases =====
|
||||
// TODO: Register use cases when they are implemented
|
||||
|
||||
@@ -28,3 +28,23 @@ class UnauthorizedException implements Exception {
|
||||
final String message;
|
||||
UnauthorizedException([this.message = 'Unauthorized access']);
|
||||
}
|
||||
|
||||
class AuthenticationException implements Exception {
|
||||
final String message;
|
||||
AuthenticationException([this.message = 'Authentication failed']);
|
||||
}
|
||||
|
||||
class InvalidCredentialsException implements Exception {
|
||||
final String message;
|
||||
InvalidCredentialsException([this.message = 'Invalid email or password']);
|
||||
}
|
||||
|
||||
class TokenExpiredException implements Exception {
|
||||
final String message;
|
||||
TokenExpiredException([this.message = 'Token has expired']);
|
||||
}
|
||||
|
||||
class ConflictException implements Exception {
|
||||
final String message;
|
||||
ConflictException([this.message = 'Resource already exists']);
|
||||
}
|
||||
|
||||
@@ -39,3 +39,23 @@ class NotFoundFailure extends Failure {
|
||||
class UnauthorizedFailure extends Failure {
|
||||
const UnauthorizedFailure([super.message = 'Unauthorized access']);
|
||||
}
|
||||
|
||||
/// Authentication failure
|
||||
class AuthenticationFailure extends Failure {
|
||||
const AuthenticationFailure([super.message = 'Authentication failed']);
|
||||
}
|
||||
|
||||
/// Invalid credentials failure
|
||||
class InvalidCredentialsFailure extends Failure {
|
||||
const InvalidCredentialsFailure([super.message = 'Invalid email or password']);
|
||||
}
|
||||
|
||||
/// Token expired failure
|
||||
class TokenExpiredFailure extends Failure {
|
||||
const TokenExpiredFailure([super.message = 'Token has expired']);
|
||||
}
|
||||
|
||||
/// Conflict failure (e.g., email already exists)
|
||||
class ConflictFailure extends Failure {
|
||||
const ConflictFailure([super.message = 'Resource already exists']);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'api_interceptor.dart';
|
||||
/// Dio HTTP client configuration
|
||||
class DioClient {
|
||||
late final Dio _dio;
|
||||
String? _authToken;
|
||||
|
||||
DioClient() {
|
||||
_dio = Dio(
|
||||
@@ -21,10 +22,35 @@ class DioClient {
|
||||
);
|
||||
|
||||
_dio.interceptors.add(ApiInterceptor());
|
||||
|
||||
// Add auth interceptor to inject token
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (_authToken != null) {
|
||||
options.headers[ApiConstants.authorization] = 'Bearer $_authToken';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
|
||||
/// Set authentication token for all future requests
|
||||
void setAuthToken(String token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
/// Clear authentication token
|
||||
void clearAuthToken() {
|
||||
_authToken = null;
|
||||
}
|
||||
|
||||
/// Check if auth token is set
|
||||
bool get hasAuthToken => _authToken != null;
|
||||
|
||||
/// GET request
|
||||
Future<Response> get(
|
||||
String path, {
|
||||
|
||||
60
lib/core/storage/secure_storage.dart
Normal file
60
lib/core/storage/secure_storage.dart
Normal file
@@ -0,0 +1,60 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
/// Secure storage service for storing sensitive data like JWT tokens
|
||||
class SecureStorage {
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
// Storage keys
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
|
||||
SecureStorage({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
/// Save access token
|
||||
Future<void> saveAccessToken(String token) async {
|
||||
await _storage.write(key: _accessTokenKey, value: token);
|
||||
}
|
||||
|
||||
/// Get access token
|
||||
Future<String?> getAccessToken() async {
|
||||
return await _storage.read(key: _accessTokenKey);
|
||||
}
|
||||
|
||||
/// Save refresh token (for future use)
|
||||
Future<void> saveRefreshToken(String token) async {
|
||||
await _storage.write(key: _refreshTokenKey, value: token);
|
||||
}
|
||||
|
||||
/// Get refresh token (for future use)
|
||||
Future<String?> getRefreshToken() async {
|
||||
return await _storage.read(key: _refreshTokenKey);
|
||||
}
|
||||
|
||||
/// Delete access token
|
||||
Future<void> deleteAccessToken() async {
|
||||
await _storage.delete(key: _accessTokenKey);
|
||||
}
|
||||
|
||||
/// Delete refresh token
|
||||
Future<void> deleteRefreshToken() async {
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
}
|
||||
|
||||
/// Delete all tokens (logout)
|
||||
Future<void> deleteAllTokens() async {
|
||||
await _storage.delete(key: _accessTokenKey);
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
}
|
||||
|
||||
/// Check if access token exists
|
||||
Future<bool> hasAccessToken() async {
|
||||
final token = await getAccessToken();
|
||||
return token != null && token.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Clear all secure storage
|
||||
Future<void> clearAll() async {
|
||||
await _storage.deleteAll();
|
||||
}
|
||||
}
|
||||
472
lib/features/auth/README.md
Normal file
472
lib/features/auth/README.md
Normal file
@@ -0,0 +1,472 @@
|
||||
# Authentication Feature
|
||||
|
||||
Complete JWT-based authentication system for the Retail POS app.
|
||||
|
||||
## Overview
|
||||
|
||||
This feature implements a production-ready authentication system with:
|
||||
- User login with email/password
|
||||
- User registration
|
||||
- JWT token management with secure storage
|
||||
- Automatic token injection in API requests
|
||||
- Profile management
|
||||
- Token refresh capability
|
||||
- Proper error handling
|
||||
|
||||
## Architecture
|
||||
|
||||
The authentication feature follows Clean Architecture principles:
|
||||
|
||||
```
|
||||
auth/
|
||||
├── domain/ # Business logic layer
|
||||
│ ├── entities/ # Core business objects
|
||||
│ │ ├── user.dart
|
||||
│ │ └── auth_response.dart
|
||||
│ └── repositories/ # Repository interfaces
|
||||
│ └── auth_repository.dart
|
||||
│
|
||||
├── data/ # Data layer
|
||||
│ ├── models/ # Data transfer objects and models
|
||||
│ │ ├── login_dto.dart
|
||||
│ │ ├── register_dto.dart
|
||||
│ │ ├── user_model.dart
|
||||
│ │ └── auth_response_model.dart
|
||||
│ ├── datasources/ # Remote data sources
|
||||
│ │ └── auth_remote_datasource.dart
|
||||
│ └── repositories/ # Repository implementations
|
||||
│ └── auth_repository_impl.dart
|
||||
│
|
||||
└── presentation/ # UI layer
|
||||
├── providers/ # Riverpod state management
|
||||
│ └── auth_provider.dart
|
||||
├── pages/ # UI screens
|
||||
│ ├── login_page.dart
|
||||
│ └── register_page.dart
|
||||
└── widgets/ # Reusable UI components
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Base URL: `http://localhost:3000/api`
|
||||
|
||||
### 1. Login
|
||||
```
|
||||
POST /auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
Request:
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!"
|
||||
}
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"name": "John Doe",
|
||||
"email": "user@example.com",
|
||||
"roles": ["user"],
|
||||
"isActive": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Register
|
||||
```
|
||||
POST /auth/register
|
||||
Content-Type: application/json
|
||||
|
||||
Request:
|
||||
{
|
||||
"name": "John Doe",
|
||||
"email": "user@example.com",
|
||||
"password": "Password123!",
|
||||
"roles": ["user"]
|
||||
}
|
||||
|
||||
Response (201):
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Get Profile
|
||||
```
|
||||
GET /auth/profile
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "John Doe",
|
||||
"email": "user@example.com",
|
||||
"roles": ["user"],
|
||||
"isActive": true,
|
||||
"createdAt": "2025-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2025-01-01T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Refresh Token
|
||||
```
|
||||
POST /auth/refresh
|
||||
Authorization: Bearer {token}
|
||||
|
||||
Response (200):
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||
"user": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Setup (Already configured in injection_container.dart)
|
||||
|
||||
The authentication system is registered in the dependency injection container:
|
||||
|
||||
```dart
|
||||
// In lib/core/di/injection_container.dart
|
||||
Future<void> initDependencies() async {
|
||||
// Secure Storage
|
||||
sl.registerLazySingleton<SecureStorage>(() => SecureStorage());
|
||||
|
||||
// Auth Remote Data Source
|
||||
sl.registerLazySingleton<AuthRemoteDataSource>(
|
||||
() => AuthRemoteDataSourceImpl(dioClient: sl()),
|
||||
);
|
||||
|
||||
// Auth Repository
|
||||
sl.registerLazySingleton<AuthRepository>(
|
||||
() => AuthRepositoryImpl(
|
||||
remoteDataSource: sl(),
|
||||
secureStorage: sl(),
|
||||
dioClient: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Login Flow
|
||||
|
||||
```dart
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:retail/features/auth/presentation/providers/auth_provider.dart';
|
||||
|
||||
// In a widget
|
||||
class MyWidget extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: 'user@example.com',
|
||||
password: 'Password123!',
|
||||
);
|
||||
|
||||
if (success) {
|
||||
// Navigate to home
|
||||
} else {
|
||||
// Show error
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
print('Login failed: $error');
|
||||
}
|
||||
},
|
||||
child: Text('Login'),
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Check Authentication Status
|
||||
|
||||
```dart
|
||||
// Check if user is authenticated
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
|
||||
// Get current user
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
if (user != null) {
|
||||
print('Welcome ${user.name}!');
|
||||
print('Roles: ${user.roles}');
|
||||
print('Is Admin: ${user.isAdmin}');
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Logout
|
||||
|
||||
```dart
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
```
|
||||
|
||||
### 5. Register New User
|
||||
|
||||
```dart
|
||||
final success = await ref.read(authProvider.notifier).register(
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
password: 'Password123!',
|
||||
roles: ['user'], // Optional, defaults to ['user']
|
||||
);
|
||||
```
|
||||
|
||||
### 6. Refresh Token
|
||||
|
||||
```dart
|
||||
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||
|
||||
if (!success) {
|
||||
// Token refresh failed, user logged out automatically
|
||||
}
|
||||
```
|
||||
|
||||
## Bearer Token Injection
|
||||
|
||||
The authentication system automatically injects the JWT bearer token into all API requests:
|
||||
|
||||
### How it Works
|
||||
|
||||
1. **On Login/Register**: Token is saved to secure storage and set in DioClient
|
||||
2. **Automatic Injection**: DioClient interceptor adds `Authorization: Bearer {token}` header to all requests
|
||||
3. **On Logout**: Token is cleared from secure storage and DioClient
|
||||
4. **On App Start**: Token is loaded from secure storage and set in DioClient if valid
|
||||
|
||||
### Implementation Details
|
||||
|
||||
```dart
|
||||
// In lib/core/network/dio_client.dart
|
||||
class DioClient {
|
||||
String? _authToken;
|
||||
|
||||
DioClient() {
|
||||
_dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onRequest: (options, handler) {
|
||||
if (_authToken != null) {
|
||||
options.headers['Authorization'] = 'Bearer $_authToken';
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void setAuthToken(String token) {
|
||||
_authToken = token;
|
||||
}
|
||||
|
||||
void clearAuthToken() {
|
||||
_authToken = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Token Management (Advanced)
|
||||
|
||||
If you need to manually manage tokens:
|
||||
|
||||
```dart
|
||||
// Get token from storage
|
||||
final token = await sl<SecureStorage>().getAccessToken();
|
||||
|
||||
// Set token in DioClient
|
||||
sl<DioClient>().setAuthToken(token!);
|
||||
|
||||
// Clear token
|
||||
sl<DioClient>().clearAuthToken();
|
||||
await sl<SecureStorage>().deleteAllTokens();
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The authentication system handles various error scenarios:
|
||||
|
||||
### Error Types
|
||||
|
||||
1. **InvalidCredentialsFailure**: Wrong email or password
|
||||
2. **ConflictFailure**: Email already exists (registration)
|
||||
3. **UnauthorizedFailure**: Invalid or expired token
|
||||
4. **NetworkFailure**: No internet connection
|
||||
5. **ServerFailure**: Server errors (500, 503, etc.)
|
||||
6. **ValidationFailure**: Invalid input data
|
||||
|
||||
### Error Handling Example
|
||||
|
||||
```dart
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: email,
|
||||
password: password,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
|
||||
// Show user-friendly error message
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error ?? 'Login failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Secure Storage
|
||||
|
||||
JWT tokens are stored securely using `flutter_secure_storage`:
|
||||
|
||||
- **iOS**: Keychain
|
||||
- **Android**: EncryptedSharedPreferences
|
||||
- **Web**: Web Crypto API
|
||||
- **Desktop**: Platform-specific secure storage
|
||||
|
||||
```dart
|
||||
// Access secure storage
|
||||
final secureStorage = sl<SecureStorage>();
|
||||
|
||||
// Save token
|
||||
await secureStorage.saveAccessToken(token);
|
||||
|
||||
// Get token
|
||||
final token = await secureStorage.getAccessToken();
|
||||
|
||||
// Check if token exists
|
||||
final hasToken = await secureStorage.hasAccessToken();
|
||||
|
||||
// Delete token
|
||||
await secureStorage.deleteAllTokens();
|
||||
```
|
||||
|
||||
## State Management
|
||||
|
||||
The authentication state is managed using Riverpod 3.0:
|
||||
|
||||
### AuthState
|
||||
|
||||
```dart
|
||||
class AuthState {
|
||||
final User? user; // Current authenticated user
|
||||
final bool isAuthenticated; // Authentication status
|
||||
final bool isLoading; // Loading state
|
||||
final String? errorMessage; // Error message (if any)
|
||||
}
|
||||
```
|
||||
|
||||
### Watching Auth State
|
||||
|
||||
```dart
|
||||
// Watch entire auth state
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// Access specific properties
|
||||
final user = authState.user;
|
||||
final isLoading = authState.isLoading;
|
||||
final error = authState.errorMessage;
|
||||
|
||||
// Or use convenience providers
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
```
|
||||
|
||||
## Protected Routes
|
||||
|
||||
Implement route guards to protect authenticated routes:
|
||||
|
||||
```dart
|
||||
class AuthGuard extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const AuthGuard({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||
|
||||
if (isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return const LoginPage();
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
MaterialApp(
|
||||
home: AuthGuard(
|
||||
child: HomePage(),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```dart
|
||||
test('login should return success with valid credentials', () async {
|
||||
// Arrange
|
||||
final repository = MockAuthRepository();
|
||||
when(repository.login(email: any, password: any))
|
||||
.thenAnswer((_) async => Right(mockAuthResponse));
|
||||
|
||||
// Act
|
||||
final result = await repository.login(
|
||||
email: 'test@example.com',
|
||||
password: 'Password123!',
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result.isRight(), true);
|
||||
});
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
|
||||
Test the complete authentication flow from UI to API.
|
||||
|
||||
## Code Generation
|
||||
|
||||
Run code generation for Riverpod providers:
|
||||
|
||||
```bash
|
||||
# Generate auth_provider.g.dart
|
||||
flutter pub run build_runner build --delete-conflicting-outputs
|
||||
|
||||
# Watch for changes
|
||||
flutter pub run build_runner watch
|
||||
```
|
||||
|
||||
## Production Considerations
|
||||
|
||||
1. **HTTPS**: Always use HTTPS in production
|
||||
2. **Token Expiry**: Implement automatic token refresh on 401 errors
|
||||
3. **Biometric Auth**: Add fingerprint/face ID support
|
||||
4. **Password Strength**: Enforce strong password requirements
|
||||
5. **Rate Limiting**: Handle rate limiting (429 errors)
|
||||
6. **Secure Storage**: Tokens are already stored securely
|
||||
7. **Session Management**: Clear tokens on app uninstall
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Biometric authentication (fingerprint, face ID)
|
||||
- [ ] Remember me functionality
|
||||
- [ ] Password reset flow
|
||||
- [ ] Email verification
|
||||
- [ ] Social login (Google, Apple, Facebook)
|
||||
- [ ] Multi-factor authentication (MFA)
|
||||
- [ ] Session timeout warning
|
||||
- [ ] Device management
|
||||
159
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
159
lib/features/auth/data/datasources/auth_remote_datasource.dart
Normal file
@@ -0,0 +1,159 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../core/constants/api_constants.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../models/auth_response_model.dart';
|
||||
import '../models/login_dto.dart';
|
||||
import '../models/register_dto.dart';
|
||||
import '../models/user_model.dart';
|
||||
|
||||
/// Remote data source for authentication operations
|
||||
abstract class AuthRemoteDataSource {
|
||||
/// Login user with email and password
|
||||
Future<AuthResponseModel> login(LoginDto loginDto);
|
||||
|
||||
/// Register new user
|
||||
Future<AuthResponseModel> register(RegisterDto registerDto);
|
||||
|
||||
/// Get current user profile
|
||||
Future<UserModel> getProfile();
|
||||
|
||||
/// Refresh access token
|
||||
Future<AuthResponseModel> refreshToken();
|
||||
}
|
||||
|
||||
/// Implementation of AuthRemoteDataSource
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
final DioClient dioClient;
|
||||
|
||||
AuthRemoteDataSourceImpl({required this.dioClient});
|
||||
|
||||
@override
|
||||
Future<AuthResponseModel> login(LoginDto loginDto) async {
|
||||
try {
|
||||
final response = await dioClient.post(
|
||||
ApiConstants.login,
|
||||
data: loginDto.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == ApiConstants.statusOk) {
|
||||
return AuthResponseModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Login failed with status: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error during login: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResponseModel> register(RegisterDto registerDto) async {
|
||||
try {
|
||||
final response = await dioClient.post(
|
||||
ApiConstants.register,
|
||||
data: registerDto.toJson(),
|
||||
);
|
||||
|
||||
if (response.statusCode == ApiConstants.statusCreated) {
|
||||
return AuthResponseModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Registration failed with status: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error during registration: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserModel> getProfile() async {
|
||||
try {
|
||||
final response = await dioClient.get(ApiConstants.profile);
|
||||
|
||||
if (response.statusCode == ApiConstants.statusOk) {
|
||||
return UserModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Get profile failed with status: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error getting profile: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AuthResponseModel> refreshToken() async {
|
||||
try {
|
||||
final response = await dioClient.post(ApiConstants.refreshToken);
|
||||
|
||||
if (response.statusCode == ApiConstants.statusOk) {
|
||||
return AuthResponseModel.fromJson(response.data);
|
||||
} else {
|
||||
throw ServerException('Token refresh failed with status: ${response.statusCode}');
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
} catch (e) {
|
||||
throw ServerException('Unexpected error refreshing token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle Dio errors and convert to custom exceptions
|
||||
Exception _handleDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return NetworkException('Connection timeout. Please check your internet connection.');
|
||||
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
final message = error.response?.data?['message'] ?? error.message;
|
||||
|
||||
switch (statusCode) {
|
||||
case ApiConstants.statusUnauthorized:
|
||||
return InvalidCredentialsException(message ?? 'Invalid email or password');
|
||||
|
||||
case ApiConstants.statusForbidden:
|
||||
return UnauthorizedException(message ?? 'Access forbidden');
|
||||
|
||||
case ApiConstants.statusNotFound:
|
||||
return NotFoundException(message ?? 'Resource not found');
|
||||
|
||||
case ApiConstants.statusUnprocessableEntity:
|
||||
return ValidationException(message ?? 'Validation failed');
|
||||
|
||||
case 409: // Conflict
|
||||
return ConflictException(message ?? 'Email already exists');
|
||||
|
||||
case ApiConstants.statusTooManyRequests:
|
||||
return ServerException('Too many requests. Please try again later.');
|
||||
|
||||
case ApiConstants.statusInternalServerError:
|
||||
case ApiConstants.statusBadGateway:
|
||||
case ApiConstants.statusServiceUnavailable:
|
||||
case ApiConstants.statusGatewayTimeout:
|
||||
return ServerException(message ?? 'Server error. Please try again later.');
|
||||
|
||||
default:
|
||||
return ServerException(message ?? 'Unknown error occurred');
|
||||
}
|
||||
|
||||
case DioExceptionType.connectionError:
|
||||
return NetworkException('No internet connection. Please check your network.');
|
||||
|
||||
case DioExceptionType.badCertificate:
|
||||
return NetworkException('SSL certificate error');
|
||||
|
||||
case DioExceptionType.cancel:
|
||||
return NetworkException('Request was cancelled');
|
||||
|
||||
default:
|
||||
return ServerException(error.message ?? 'Unknown error occurred');
|
||||
}
|
||||
}
|
||||
}
|
||||
42
lib/features/auth/data/models/auth_response_model.dart
Normal file
42
lib/features/auth/data/models/auth_response_model.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import '../../domain/entities/auth_response.dart';
|
||||
import 'user_model.dart';
|
||||
|
||||
/// AuthResponse model for data layer (extends AuthResponse entity)
|
||||
class AuthResponseModel extends AuthResponse {
|
||||
const AuthResponseModel({
|
||||
required super.accessToken,
|
||||
required super.user,
|
||||
});
|
||||
|
||||
/// Create AuthResponseModel from JSON
|
||||
factory AuthResponseModel.fromJson(Map<String, dynamic> json) {
|
||||
return AuthResponseModel(
|
||||
accessToken: json['access_token'] as String,
|
||||
user: UserModel.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert AuthResponseModel to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'access_token': accessToken,
|
||||
'user': (user as UserModel).toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Create AuthResponseModel from AuthResponse entity
|
||||
factory AuthResponseModel.fromEntity(AuthResponse authResponse) {
|
||||
return AuthResponseModel(
|
||||
accessToken: authResponse.accessToken,
|
||||
user: authResponse.user,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to AuthResponse entity
|
||||
AuthResponse toEntity() {
|
||||
return AuthResponse(
|
||||
accessToken: accessToken,
|
||||
user: user,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/features/auth/data/models/login_dto.dart
Normal file
18
lib/features/auth/data/models/login_dto.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
/// Login request Data Transfer Object
|
||||
class LoginDto {
|
||||
final String email;
|
||||
final String password;
|
||||
|
||||
const LoginDto({
|
||||
required this.email,
|
||||
required this.password,
|
||||
});
|
||||
|
||||
/// Convert to JSON for API request
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'email': email,
|
||||
'password': password,
|
||||
};
|
||||
}
|
||||
}
|
||||
24
lib/features/auth/data/models/register_dto.dart
Normal file
24
lib/features/auth/data/models/register_dto.dart
Normal file
@@ -0,0 +1,24 @@
|
||||
/// Register request Data Transfer Object
|
||||
class RegisterDto {
|
||||
final String name;
|
||||
final String email;
|
||||
final String password;
|
||||
final List<String> roles;
|
||||
|
||||
const RegisterDto({
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.password,
|
||||
this.roles = const ['user'],
|
||||
});
|
||||
|
||||
/// Convert to JSON for API request
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'name': name,
|
||||
'email': email,
|
||||
'password': password,
|
||||
'roles': roles,
|
||||
};
|
||||
}
|
||||
}
|
||||
66
lib/features/auth/data/models/user_model.dart
Normal file
66
lib/features/auth/data/models/user_model.dart
Normal file
@@ -0,0 +1,66 @@
|
||||
import '../../domain/entities/user.dart';
|
||||
|
||||
/// User model for data layer (extends User entity)
|
||||
class UserModel extends User {
|
||||
const UserModel({
|
||||
required super.id,
|
||||
required super.name,
|
||||
required super.email,
|
||||
required super.roles,
|
||||
required super.isActive,
|
||||
required super.createdAt,
|
||||
required super.updatedAt,
|
||||
});
|
||||
|
||||
/// Create UserModel from JSON
|
||||
factory UserModel.fromJson(Map<String, dynamic> json) {
|
||||
return UserModel(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
email: json['email'] as String,
|
||||
roles: (json['roles'] as List<dynamic>).cast<String>(),
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
createdAt: DateTime.parse(json['createdAt'] as String),
|
||||
updatedAt: DateTime.parse(json['updatedAt'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert UserModel to JSON
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'email': email,
|
||||
'roles': roles,
|
||||
'isActive': isActive,
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
'updatedAt': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Create UserModel from User entity
|
||||
factory UserModel.fromEntity(User user) {
|
||||
return UserModel(
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roles: user.roles,
|
||||
isActive: user.isActive,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convert to User entity
|
||||
User toEntity() {
|
||||
return User(
|
||||
id: id,
|
||||
name: name,
|
||||
email: email,
|
||||
roles: roles,
|
||||
isActive: isActive,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
175
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
175
lib/features/auth/data/repositories/auth_repository_impl.dart
Normal file
@@ -0,0 +1,175 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/exceptions.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/dio_client.dart';
|
||||
import '../../../../core/storage/secure_storage.dart';
|
||||
import '../../domain/entities/auth_response.dart';
|
||||
import '../../domain/entities/user.dart';
|
||||
import '../../domain/repositories/auth_repository.dart';
|
||||
import '../datasources/auth_remote_datasource.dart';
|
||||
import '../models/login_dto.dart';
|
||||
import '../models/register_dto.dart';
|
||||
|
||||
/// Implementation of AuthRepository
|
||||
class AuthRepositoryImpl implements AuthRepository {
|
||||
final AuthRemoteDataSource remoteDataSource;
|
||||
final SecureStorage secureStorage;
|
||||
final DioClient dioClient;
|
||||
|
||||
AuthRepositoryImpl({
|
||||
required this.remoteDataSource,
|
||||
required this.secureStorage,
|
||||
required this.dioClient,
|
||||
});
|
||||
|
||||
@override
|
||||
Future<Either<Failure, AuthResponse>> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
try {
|
||||
final loginDto = LoginDto(email: email, password: password);
|
||||
final authResponse = await remoteDataSource.login(loginDto);
|
||||
|
||||
// Save token to secure storage
|
||||
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||
|
||||
// Set token in Dio client for subsequent requests
|
||||
dioClient.setAuthToken(authResponse.accessToken);
|
||||
|
||||
return Right(authResponse);
|
||||
} on InvalidCredentialsException catch (e) {
|
||||
return Left(InvalidCredentialsFailure(e.message));
|
||||
} on UnauthorizedException catch (e) {
|
||||
return Left(UnauthorizedFailure(e.message));
|
||||
} on ValidationException catch (e) {
|
||||
return Left(ValidationFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, AuthResponse>> register({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
List<String> roles = const ['user'],
|
||||
}) async {
|
||||
try {
|
||||
final registerDto = RegisterDto(
|
||||
name: name,
|
||||
email: email,
|
||||
password: password,
|
||||
roles: roles,
|
||||
);
|
||||
final authResponse = await remoteDataSource.register(registerDto);
|
||||
|
||||
// Save token to secure storage
|
||||
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||
|
||||
// Set token in Dio client for subsequent requests
|
||||
dioClient.setAuthToken(authResponse.accessToken);
|
||||
|
||||
return Right(authResponse);
|
||||
} on ConflictException catch (e) {
|
||||
return Left(ConflictFailure(e.message));
|
||||
} on ValidationException catch (e) {
|
||||
return Left(ValidationFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, User>> getProfile() async {
|
||||
try {
|
||||
final user = await remoteDataSource.getProfile();
|
||||
return Right(user);
|
||||
} on UnauthorizedException catch (e) {
|
||||
return Left(UnauthorizedFailure(e.message));
|
||||
} on TokenExpiredException catch (e) {
|
||||
return Left(TokenExpiredFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, AuthResponse>> refreshToken() async {
|
||||
try {
|
||||
final authResponse = await remoteDataSource.refreshToken();
|
||||
|
||||
// Update token in secure storage
|
||||
await secureStorage.saveAccessToken(authResponse.accessToken);
|
||||
|
||||
// Update token in Dio client
|
||||
dioClient.setAuthToken(authResponse.accessToken);
|
||||
|
||||
return Right(authResponse);
|
||||
} on UnauthorizedException catch (e) {
|
||||
return Left(UnauthorizedFailure(e.message));
|
||||
} on TokenExpiredException catch (e) {
|
||||
return Left(TokenExpiredFailure(e.message));
|
||||
} on NetworkException catch (e) {
|
||||
return Left(NetworkFailure(e.message));
|
||||
} on ServerException catch (e) {
|
||||
return Left(ServerFailure(e.message));
|
||||
} catch (e) {
|
||||
return Left(ServerFailure('Unexpected error: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> logout() async {
|
||||
try {
|
||||
// Clear token from secure storage
|
||||
await secureStorage.deleteAllTokens();
|
||||
|
||||
// Clear token from Dio client
|
||||
dioClient.clearAuthToken();
|
||||
|
||||
return const Right(null);
|
||||
} catch (e) {
|
||||
return Left(CacheFailure('Failed to logout: $e'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> isAuthenticated() async {
|
||||
try {
|
||||
final hasToken = await secureStorage.hasAccessToken();
|
||||
if (hasToken) {
|
||||
final token = await secureStorage.getAccessToken();
|
||||
if (token != null) {
|
||||
dioClient.setAuthToken(token);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getAccessToken() async {
|
||||
try {
|
||||
return await secureStorage.getAccessToken();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
lib/features/auth/domain/entities/auth_response.dart
Normal file
16
lib/features/auth/domain/entities/auth_response.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'user.dart';
|
||||
|
||||
/// Authentication response entity
|
||||
class AuthResponse extends Equatable {
|
||||
final String accessToken;
|
||||
final User user;
|
||||
|
||||
const AuthResponse({
|
||||
required this.accessToken,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [accessToken, user];
|
||||
}
|
||||
45
lib/features/auth/domain/entities/user.dart
Normal file
45
lib/features/auth/domain/entities/user.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// User entity representing a user in the system
|
||||
class User extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String email;
|
||||
final List<String> roles;
|
||||
final bool isActive;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.email,
|
||||
required this.roles,
|
||||
required this.isActive,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
name,
|
||||
email,
|
||||
roles,
|
||||
isActive,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
|
||||
/// Check if user has a specific role
|
||||
bool hasRole(String role) => roles.contains(role);
|
||||
|
||||
/// Check if user is admin
|
||||
bool get isAdmin => hasRole('admin');
|
||||
|
||||
/// Check if user is manager
|
||||
bool get isManager => hasRole('manager');
|
||||
|
||||
/// Check if user is cashier
|
||||
bool get isCashier => hasRole('cashier');
|
||||
}
|
||||
36
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
36
lib/features/auth/domain/repositories/auth_repository.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/auth_response.dart';
|
||||
import '../entities/user.dart';
|
||||
|
||||
/// Abstract repository for authentication operations
|
||||
abstract class AuthRepository {
|
||||
/// Login user with email and password
|
||||
Future<Either<Failure, AuthResponse>> login({
|
||||
required String email,
|
||||
required String password,
|
||||
});
|
||||
|
||||
/// Register new user
|
||||
Future<Either<Failure, AuthResponse>> register({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
List<String> roles = const ['user'],
|
||||
});
|
||||
|
||||
/// Get current user profile
|
||||
Future<Either<Failure, User>> getProfile();
|
||||
|
||||
/// Refresh access token
|
||||
Future<Either<Failure, AuthResponse>> refreshToken();
|
||||
|
||||
/// Logout user (clear local token)
|
||||
Future<Either<Failure, void>> logout();
|
||||
|
||||
/// Check if user is authenticated
|
||||
Future<bool> isAuthenticated();
|
||||
|
||||
/// Get stored access token
|
||||
Future<String?> getAccessToken();
|
||||
}
|
||||
488
lib/features/auth/example_usage.dart
Normal file
488
lib/features/auth/example_usage.dart
Normal file
@@ -0,0 +1,488 @@
|
||||
// ignore_for_file: unused_local_variable, avoid_print
|
||||
|
||||
/// Example usage of the authentication system
|
||||
///
|
||||
/// This file demonstrates how to use the authentication feature
|
||||
/// in your Flutter app. Copy the patterns shown here into your
|
||||
/// actual implementation.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'presentation/providers/auth_provider.dart';
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 1: Login Flow
|
||||
// ============================================================================
|
||||
|
||||
class LoginExample extends ConsumerWidget {
|
||||
final TextEditingController emailController;
|
||||
final TextEditingController passwordController;
|
||||
|
||||
const LoginExample({
|
||||
super.key,
|
||||
required this.emailController,
|
||||
required this.passwordController,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Watch auth state
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Show loading indicator
|
||||
if (authState.isLoading) const CircularProgressIndicator(),
|
||||
|
||||
// Login button
|
||||
ElevatedButton(
|
||||
onPressed: authState.isLoading
|
||||
? null
|
||||
: () async {
|
||||
// Call login
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: emailController.text.trim(),
|
||||
password: passwordController.text,
|
||||
);
|
||||
|
||||
// Handle result
|
||||
if (success) {
|
||||
// Login successful - navigate to home
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
}
|
||||
} else {
|
||||
// Login failed - show error
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error ?? 'Login failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Login'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 2: Register Flow
|
||||
// ============================================================================
|
||||
|
||||
class RegisterExample extends ConsumerStatefulWidget {
|
||||
const RegisterExample({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterExample> createState() => _RegisterExampleState();
|
||||
}
|
||||
|
||||
class _RegisterExampleState extends ConsumerState<RegisterExample> {
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
final success = await ref.read(authProvider.notifier).register(
|
||||
name: _nameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
roles: ['user'], // Optional, defaults to ['user']
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
// Registration successful
|
||||
Navigator.pushReplacementNamed(context, '/home');
|
||||
} else {
|
||||
// Registration failed
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error ?? 'Registration failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return ElevatedButton(
|
||||
onPressed: authState.isLoading ? null : _handleRegister,
|
||||
child: const Text('Register'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 3: Check Authentication Status
|
||||
// ============================================================================
|
||||
|
||||
class AuthStatusExample extends ConsumerWidget {
|
||||
const AuthStatusExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// Method 1: Watch entire auth state
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// Method 2: Use convenience providers
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Check if authenticated
|
||||
if (authState.isAuthenticated) ...[
|
||||
Text('Welcome ${authState.user?.name}!'),
|
||||
Text('Email: ${authState.user?.email}'),
|
||||
Text('Roles: ${authState.user?.roles.join(", ")}'),
|
||||
|
||||
// Check user roles
|
||||
if (currentUser?.isAdmin ?? false)
|
||||
const Text('You are an admin'),
|
||||
if (currentUser?.isManager ?? false)
|
||||
const Text('You are a manager'),
|
||||
if (currentUser?.isCashier ?? false)
|
||||
const Text('You are a cashier'),
|
||||
] else ...[
|
||||
const Text('Not authenticated'),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 4: Protected Route Guard
|
||||
// ============================================================================
|
||||
|
||||
class AuthGuard extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
const AuthGuard({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isAuthenticated = ref.watch(isAuthenticatedProvider);
|
||||
final isLoading = ref.watch(authProvider.select((s) => s.isLoading));
|
||||
|
||||
// Show loading while checking auth status
|
||||
if (isLoading) {
|
||||
return const Scaffold(
|
||||
body: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// If not authenticated, show login page
|
||||
if (!isAuthenticated) {
|
||||
return const Scaffold(
|
||||
body: Center(
|
||||
child: Text('Please login to continue'),
|
||||
),
|
||||
);
|
||||
// In real app: return const LoginPage();
|
||||
}
|
||||
|
||||
// User is authenticated, show protected content
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage in main app:
|
||||
// MaterialApp(
|
||||
// home: AuthGuard(
|
||||
// child: HomePage(),
|
||||
// ),
|
||||
// );
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 5: Logout
|
||||
// ============================================================================
|
||||
|
||||
class LogoutExample extends ConsumerWidget {
|
||||
const LogoutExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
// Show confirmation dialog
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Logout'),
|
||||
content: const Text('Are you sure you want to logout?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Logout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
// Perform logout
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
|
||||
// Navigate to login
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('Logout'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 6: Get Profile (Refresh User Data)
|
||||
// ============================================================================
|
||||
|
||||
class ProfileExample extends ConsumerWidget {
|
||||
const ProfileExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (currentUser != null) ...[
|
||||
Text('Name: ${currentUser.name}'),
|
||||
Text('Email: ${currentUser.email}'),
|
||||
Text('Roles: ${currentUser.roles.join(", ")}'),
|
||||
],
|
||||
|
||||
// Refresh profile button
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).getProfile();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Profile refreshed')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('Refresh Profile'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 7: Refresh Token
|
||||
// ============================================================================
|
||||
|
||||
class RefreshTokenExample extends ConsumerWidget {
|
||||
const RefreshTokenExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final success = await ref.read(authProvider.notifier).refreshToken();
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Token refreshed successfully')),
|
||||
);
|
||||
} else {
|
||||
// Token refresh failed - user was logged out
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Session expired. Please login again.'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
},
|
||||
child: const Text('Refresh Token'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 8: Role-Based UI
|
||||
// ============================================================================
|
||||
|
||||
class RoleBasedUIExample extends ConsumerWidget {
|
||||
const RoleBasedUIExample({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentUser = ref.watch(currentUserProvider);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Show to all authenticated users
|
||||
const Text('Dashboard'),
|
||||
|
||||
// Show only to admins
|
||||
if (currentUser?.isAdmin ?? false) ...[
|
||||
const Text('Admin Panel'),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to admin panel
|
||||
},
|
||||
child: const Text('Manage Users'),
|
||||
),
|
||||
],
|
||||
|
||||
// Show only to managers
|
||||
if (currentUser?.isManager ?? false) ...[
|
||||
const Text('Manager Tools'),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to manager tools
|
||||
},
|
||||
child: const Text('View Reports'),
|
||||
),
|
||||
],
|
||||
|
||||
// Show only to cashiers
|
||||
if (currentUser?.isCashier ?? false) ...[
|
||||
const Text('POS Terminal'),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
// Navigate to POS
|
||||
},
|
||||
child: const Text('Start Transaction'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 9: Error Handling
|
||||
// ============================================================================
|
||||
|
||||
class ErrorHandlingExample extends ConsumerWidget {
|
||||
const ErrorHandlingExample({super.key});
|
||||
|
||||
Future<void> _handleLogin(BuildContext context, WidgetRef ref) async {
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
if (!success) {
|
||||
final error = ref.read(authProvider).errorMessage;
|
||||
|
||||
// Different error messages result in different UI feedback
|
||||
String userMessage;
|
||||
Color backgroundColor;
|
||||
|
||||
if (error?.contains('Invalid email or password') ?? false) {
|
||||
userMessage = 'Incorrect email or password. Please try again.';
|
||||
backgroundColor = Colors.red;
|
||||
} else if (error?.contains('Network') ?? false) {
|
||||
userMessage = 'No internet connection. Please check your network.';
|
||||
backgroundColor = Colors.orange;
|
||||
} else if (error?.contains('Server') ?? false) {
|
||||
userMessage = 'Server error. Please try again later.';
|
||||
backgroundColor = Colors.red[700]!;
|
||||
} else {
|
||||
userMessage = error ?? 'Login failed. Please try again.';
|
||||
backgroundColor = Colors.red;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(userMessage),
|
||||
backgroundColor: backgroundColor,
|
||||
action: SnackBarAction(
|
||||
label: 'Retry',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => _handleLogin(context, ref),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
onPressed: () => _handleLogin(context, ref),
|
||||
child: const Text('Login with Error Handling'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 10: Using Auth in Non-Widget Code
|
||||
// ============================================================================
|
||||
|
||||
void nonWidgetExample() {
|
||||
// If you need to access auth outside widgets (e.g., in services),
|
||||
// use the service locator directly:
|
||||
|
||||
// import 'package:retail/core/di/injection_container.dart';
|
||||
// import 'package:retail/features/auth/domain/repositories/auth_repository.dart';
|
||||
|
||||
// final authRepository = sl<AuthRepository>();
|
||||
//
|
||||
// // Check if authenticated
|
||||
// final isAuthenticated = await authRepository.isAuthenticated();
|
||||
//
|
||||
// // Get token
|
||||
// final token = await authRepository.getAccessToken();
|
||||
//
|
||||
// print('Token: $token');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// EXAMPLE 11: Automatic Token Injection Test
|
||||
// ============================================================================
|
||||
|
||||
void tokenInjectionExample() {
|
||||
// Once logged in, all API requests automatically include the JWT token:
|
||||
//
|
||||
// The DioClient interceptor adds this header to all requests:
|
||||
// Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
//
|
||||
// You don't need to manually add the token - it's automatic!
|
||||
|
||||
// Example of making an API call after login:
|
||||
// final response = await sl<DioClient>().get('/api/products');
|
||||
//
|
||||
// The above request will automatically include:
|
||||
// Headers: {
|
||||
// "Authorization": "Bearer <your-jwt-token>",
|
||||
// "Content-Type": "application/json",
|
||||
// "Accept": "application/json"
|
||||
// }
|
||||
}
|
||||
171
lib/features/auth/presentation/pages/login_page.dart
Normal file
171
lib/features/auth/presentation/pages/login_page.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Login page for user authentication
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final success = await ref.read(authProvider.notifier).login(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
// Navigate to home or show success
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Login successful!')),
|
||||
);
|
||||
// TODO: Navigate to home page
|
||||
} else {
|
||||
final errorMessage = ref.read(authProvider).errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage ?? 'Login failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Login'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Logo or app name
|
||||
Icon(
|
||||
Icons.shopping_cart,
|
||||
size: 80,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Retail POS',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Email field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Login button
|
||||
FilledButton(
|
||||
onPressed: authState.isLoading ? null : _handleLogin,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Login'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Register link
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// TODO: Navigate to register page
|
||||
// Navigator.push(context, MaterialPageRoute(builder: (_) => const RegisterPage()));
|
||||
},
|
||||
child: const Text('Don\'t have an account? Register'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
234
lib/features/auth/presentation/pages/register_page.dart
Normal file
234
lib/features/auth/presentation/pages/register_page.dart
Normal file
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
/// Register page for new user registration
|
||||
class RegisterPage extends ConsumerStatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends ConsumerState<RegisterPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleRegister() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final success = await ref.read(authProvider.notifier).register(
|
||||
name: _nameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (success) {
|
||||
// Navigate to home or show success
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Registration successful!')),
|
||||
);
|
||||
// TODO: Navigate to home page
|
||||
} else {
|
||||
final errorMessage = ref.read(authProvider).errorMessage;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(errorMessage ?? 'Registration failed'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Register'),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Logo or app name
|
||||
Icon(
|
||||
Icons.shopping_cart,
|
||||
size: 80,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Create Account',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium,
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
// Name field
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Full Name',
|
||||
prefixIcon: Icon(Icons.person),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your name';
|
||||
}
|
||||
if (value.length < 2) {
|
||||
return 'Name must be at least 2 characters';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Email field
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
prefixIcon: Icon(Icons.email),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your email';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return 'Please enter a valid email';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Password field
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
prefixIcon: const Icon(Icons.lock),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter your password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
// Check for uppercase, lowercase, and number
|
||||
if (!RegExp(r'(?=.*[a-z])(?=.*[A-Z])(?=.*\d)').hasMatch(value)) {
|
||||
return 'Password must contain uppercase, lowercase, and number';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Confirm password field
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Register button
|
||||
FilledButton(
|
||||
onPressed: authState.isLoading ? null : _handleRegister,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: authState.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('Register'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Login link
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('Already have an account? Login'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
215
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
215
lib/features/auth/presentation/providers/auth_provider.dart
Normal file
@@ -0,0 +1,215 @@
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../domain/entities/user.dart';
|
||||
import '../../domain/repositories/auth_repository.dart';
|
||||
|
||||
part 'auth_provider.g.dart';
|
||||
|
||||
/// Provider for AuthRepository
|
||||
@riverpod
|
||||
AuthRepository authRepository(Ref ref) {
|
||||
return sl<AuthRepository>();
|
||||
}
|
||||
|
||||
/// Auth state class
|
||||
class AuthState {
|
||||
final User? user;
|
||||
final bool isAuthenticated;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
const AuthState({
|
||||
this.user,
|
||||
this.isAuthenticated = false,
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
User? user,
|
||||
bool? isAuthenticated,
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return AuthState(
|
||||
user: user ?? this.user,
|
||||
isAuthenticated: isAuthenticated ?? this.isAuthenticated,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth state notifier provider
|
||||
@riverpod
|
||||
class Auth extends _$Auth {
|
||||
@override
|
||||
AuthState build() {
|
||||
_checkAuthStatus();
|
||||
return const AuthState();
|
||||
}
|
||||
|
||||
AuthRepository get _repository => ref.read(authRepositoryProvider);
|
||||
|
||||
/// Check if user is authenticated on app start
|
||||
Future<void> _checkAuthStatus() async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
final isAuthenticated = await _repository.isAuthenticated();
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Get user profile
|
||||
final result = await _repository.getProfile();
|
||||
result.fold(
|
||||
(failure) {
|
||||
state = const AuthState(
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
);
|
||||
},
|
||||
(user) {
|
||||
state = AuthState(
|
||||
user: user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
state = const AuthState(
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Login user
|
||||
Future<bool> login({
|
||||
required String email,
|
||||
required String password,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
final result = await _repository.login(email: email, password: password);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: failure.message,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(authResponse) {
|
||||
state = AuthState(
|
||||
user: authResponse.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Register new user
|
||||
Future<bool> register({
|
||||
required String name,
|
||||
required String email,
|
||||
required String password,
|
||||
List<String> roles = const ['user'],
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
final result = await _repository.register(
|
||||
name: name,
|
||||
email: email,
|
||||
password: password,
|
||||
roles: roles,
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: failure.message,
|
||||
);
|
||||
return false;
|
||||
},
|
||||
(authResponse) {
|
||||
state = AuthState(
|
||||
user: authResponse.user,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Get user profile (refresh user data)
|
||||
Future<void> getProfile() async {
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
final result = await _repository.getProfile();
|
||||
|
||||
result.fold(
|
||||
(failure) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: failure.message,
|
||||
);
|
||||
},
|
||||
(user) {
|
||||
state = state.copyWith(
|
||||
user: user,
|
||||
isLoading: false,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh access token
|
||||
Future<bool> refreshToken() async {
|
||||
final result = await _repository.refreshToken();
|
||||
|
||||
return result.fold(
|
||||
(failure) {
|
||||
// If token refresh fails, logout user
|
||||
logout();
|
||||
return false;
|
||||
},
|
||||
(authResponse) {
|
||||
state = state.copyWith(user: authResponse.user);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Logout user
|
||||
Future<void> logout() async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
await _repository.logout();
|
||||
|
||||
state = const AuthState(
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current authenticated user provider
|
||||
@riverpod
|
||||
User? currentUser(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.user;
|
||||
}
|
||||
|
||||
/// Is authenticated provider
|
||||
@riverpod
|
||||
bool isAuthenticated(Ref ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
return authState.isAuthenticated;
|
||||
}
|
||||
204
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
204
lib/features/auth/presentation/providers/auth_provider.g.dart
Normal file
@@ -0,0 +1,204 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'auth_provider.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
/// Provider for AuthRepository
|
||||
|
||||
@ProviderFor(authRepository)
|
||||
const authRepositoryProvider = AuthRepositoryProvider._();
|
||||
|
||||
/// Provider for AuthRepository
|
||||
|
||||
final class AuthRepositoryProvider
|
||||
extends $FunctionalProvider<AuthRepository, AuthRepository, AuthRepository>
|
||||
with $Provider<AuthRepository> {
|
||||
/// Provider for AuthRepository
|
||||
const AuthRepositoryProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authRepositoryProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authRepositoryHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<AuthRepository> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
AuthRepository create(Ref ref) {
|
||||
return authRepository(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthRepository value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthRepository>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authRepositoryHash() => r'0483b13ac95333b56a1a82f6c9fdb64ae46f287d';
|
||||
|
||||
/// Auth state notifier provider
|
||||
|
||||
@ProviderFor(Auth)
|
||||
const authProvider = AuthProvider._();
|
||||
|
||||
/// Auth state notifier provider
|
||||
final class AuthProvider extends $NotifierProvider<Auth, AuthState> {
|
||||
/// Auth state notifier provider
|
||||
const AuthProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'authProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$authHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
Auth create() => Auth();
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(AuthState value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<AuthState>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$authHash() => r'c88e150224fa855ed0ddfba30bef9e2b289f329d';
|
||||
|
||||
/// Auth state notifier provider
|
||||
|
||||
abstract class _$Auth extends $Notifier<AuthState> {
|
||||
AuthState build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final created = build();
|
||||
final ref = this.ref as $Ref<AuthState, AuthState>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AuthState, AuthState>,
|
||||
AuthState,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleValue(ref, created);
|
||||
}
|
||||
}
|
||||
|
||||
/// Current authenticated user provider
|
||||
|
||||
@ProviderFor(currentUser)
|
||||
const currentUserProvider = CurrentUserProvider._();
|
||||
|
||||
/// Current authenticated user provider
|
||||
|
||||
final class CurrentUserProvider extends $FunctionalProvider<User?, User?, User?>
|
||||
with $Provider<User?> {
|
||||
/// Current authenticated user provider
|
||||
const CurrentUserProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'currentUserProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$currentUserHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<User?> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
User? create(Ref ref) {
|
||||
return currentUser(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(User? value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<User?>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$currentUserHash() => r'4c8cb60cef35a4fd001291434558037d6c85faf5';
|
||||
|
||||
/// Is authenticated provider
|
||||
|
||||
@ProviderFor(isAuthenticated)
|
||||
const isAuthenticatedProvider = IsAuthenticatedProvider._();
|
||||
|
||||
/// Is authenticated provider
|
||||
|
||||
final class IsAuthenticatedProvider
|
||||
extends $FunctionalProvider<bool, bool, bool>
|
||||
with $Provider<bool> {
|
||||
/// Is authenticated provider
|
||||
const IsAuthenticatedProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'isAuthenticatedProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$isAuthenticatedHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<bool> $createElement($ProviderPointer pointer) =>
|
||||
$ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
bool create(Ref ref) {
|
||||
return isAuthenticated(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(bool value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<bool>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$isAuthenticatedHash() => r'003f7e85bfa5ae774792659ce771b5b59ebf04f8';
|
||||
60
pubspec.lock
60
pubspec.lock
@@ -398,6 +398,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.2.4"
|
||||
flutter_secure_storage_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_linux
|
||||
sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.3"
|
||||
flutter_secure_storage_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_macos
|
||||
sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
flutter_secure_storage_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_platform_interface
|
||||
sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
flutter_secure_storage_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_web
|
||||
sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
flutter_secure_storage_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_secure_storage_windows
|
||||
sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
@@ -532,10 +580,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
version: "0.6.7"
|
||||
json_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1093,6 +1141,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -53,6 +53,9 @@ dependencies:
|
||||
dio: ^5.7.0
|
||||
connectivity_plus: ^6.1.1
|
||||
|
||||
# Secure Storage
|
||||
flutter_secure_storage: ^9.2.2
|
||||
|
||||
# Image Caching
|
||||
cached_network_image: ^3.4.1
|
||||
|
||||
|
||||
Reference in New Issue
Block a user