This commit is contained in:
Leon Bösche
2026-01-08 13:07:07 +01:00
parent 87ee5f2ae3
commit 5cb99815a0
20 changed files with 1869 additions and 202 deletions

View File

@@ -1,38 +1,243 @@
import 'package:bloc/bloc.dart';
import '../session/session_bloc.dart';
import '../session/session_event.dart';
import 'auth_event.dart';
import 'auth_state.dart';
import '../../services/api_client.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) {
final ApiClient apiClient;
final SessionBloc sessionBloc;
AuthBloc({required this.apiClient, required this.sessionBloc})
: super(AuthInitial()) {
on<SignupStarted>(_onSignupStarted);
on<RegistrationChallengeRequested>(_onRegistrationChallengeRequested);
on<RegistrationResponseSubmitted>(_onRegistrationResponseSubmitted);
on<LoginRequested>(_onLoginRequested);
on<PasswordLoginRequested>(_onPasswordLoginRequested);
on<AuthenticationChallengeRequested>(_onAuthenticationChallengeRequested);
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthRequested>(_onCheckAuthRequested);
}
void _onLoginRequested(LoginRequested event, Emitter<AuthState> emit) async {
emit(AuthLoading());
// Redirect to Go auth/login
// For web, use window.location or url_launcher
// Assume handled in UI
emit(const AuthFailure('Redirect to login URL'));
Future<void> _onSignupStarted(
SignupStarted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Creating account...'));
try {
final response = await apiClient.post(
'/auth/signup',
data: {
'username': event.username,
'email': event.email,
'displayName': event.displayName,
'password': event.password ?? '',
},
fromJson: (data) => data,
);
final userId = response['userId'];
emit(
SignupInProgress(
username: event.username,
email: event.email,
displayName: event.displayName,
),
);
// Trigger registration challenge request
add(RegistrationChallengeRequested(userId: userId));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
void _onLogoutRequested(
Future<void> _onRegistrationChallengeRequested(
RegistrationChallengeRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Getting registration challenge...'));
try {
final response = await apiClient.post(
'/auth/registration-challenge',
data: {'userId': event.userId},
fromJson: (data) => data,
);
emit(
RegistrationChallengeReceived(
userId: event.userId,
challenge: response['challenge'],
rpName: response['rp']['name'],
rpId: response['rp']['id'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onRegistrationResponseSubmitted(
RegistrationResponseSubmitted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Verifying registration...'));
try {
final response = await apiClient.post(
'/auth/registration-verify',
data: {
'userId': event.userId,
'challenge': event.challenge,
'credentialId': event.credentialId,
'publicKey': event.publicKey,
'clientDataJSON': event.clientDataJSON,
'attestationObject': event.attestationObject,
},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Requesting authentication challenge...'));
try {
add(AuthenticationChallengeRequested(username: event.username));
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onAuthenticationChallengeRequested(
AuthenticationChallengeRequested event,
Emitter<AuthState> emit,
) async {
try {
final response = await apiClient.post(
'/auth/authentication-challenge',
data: {'username': event.username},
fromJson: (data) => data,
);
final credentialIds =
(response['allowCredentials'] as List?)
?.map((id) => id.toString())
.toList() ??
[];
emit(
AuthenticationChallengeReceived(
challenge: response['challenge'],
credentialIds: credentialIds,
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onAuthenticationResponseSubmitted(
AuthenticationResponseSubmitted event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Verifying authentication...'));
try {
final response = await apiClient.post(
'/auth/authentication-verify',
data: {
'username': event.username,
'challenge': event.challenge,
'credentialId': event.credentialId,
'authenticatorData': event.authenticatorData,
'clientDataJSON': event.clientDataJSON,
'signature': event.signature,
},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure(e.toString()));
}
}
Future<void> _onLogoutRequested(
LogoutRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
// Call logout API
emit(AuthLoading(message: 'Logging out...'));
await Future.delayed(const Duration(milliseconds: 500));
sessionBloc.add(SessionExpired());
emit(AuthUnauthenticated());
}
void _onCheckAuthRequested(
Future<void> _onPasswordLoginRequested(
PasswordLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading(message: 'Authenticating...'));
try {
final response = await apiClient.post(
'/auth/password-login',
data: {'username': event.username, 'password': event.password},
fromJson: (data) => data,
);
final token = response['token'];
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
} catch (e) {
emit(AuthFailure('Login failed: ${e.toString()}'));
}
}
Future<void> _onCheckAuthRequested(
CheckAuthRequested event,
Emitter<AuthState> emit,
) async {
// Check if token is valid
// For now, assume unauthenticated
// Check if token is valid in SessionBloc
emit(AuthUnauthenticated());
}
}

View File

@@ -3,12 +3,135 @@ import 'package:equatable/equatable.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@override
List<Object?> get props => [];
}
// Signup events
class SignupStarted extends AuthEvent {
final String username;
final String email;
final String displayName;
final String? password;
const SignupStarted({
required this.username,
required this.email,
required this.displayName,
this.password,
});
@override
List<Object?> get props => [username, email, displayName, password];
}
class RegistrationChallengeRequested extends AuthEvent {
final String userId;
const RegistrationChallengeRequested({required this.userId});
@override
List<Object> get props => [userId];
}
class RegistrationResponseSubmitted extends AuthEvent {
final String userId;
final String challenge;
final String credentialId;
final String publicKey;
final String clientDataJSON;
final String attestationObject;
const RegistrationResponseSubmitted({
required this.userId,
required this.challenge,
required this.credentialId,
required this.publicKey,
required this.clientDataJSON,
required this.attestationObject,
});
@override
List<Object> get props => [
userId,
challenge,
credentialId,
publicKey,
clientDataJSON,
attestationObject,
];
}
// Login events
class LoginRequested extends AuthEvent {
final String username;
const LoginRequested({required this.username});
@override
List<Object> get props => [username];
}
class AuthenticationChallengeRequested extends AuthEvent {
final String username;
const AuthenticationChallengeRequested({required this.username});
@override
List<Object> get props => [username];
}
class AuthenticationResponseSubmitted extends AuthEvent {
final String username;
final String challenge;
final String credentialId;
final String authenticatorData;
final String clientDataJSON;
final String signature;
const AuthenticationResponseSubmitted({
required this.username,
required this.challenge,
required this.credentialId,
required this.authenticatorData,
required this.clientDataJSON,
required this.signature,
});
@override
List<Object> get props => [
username,
challenge,
credentialId,
authenticatorData,
clientDataJSON,
signature,
];
}
class LogoutRequested extends AuthEvent {
const LogoutRequested();
@override
List<Object> get props => [];
}
class LoginRequested extends AuthEvent {}
class CheckAuthRequested extends AuthEvent {
const CheckAuthRequested();
class LogoutRequested extends AuthEvent {}
@override
List<Object> get props => [];
}
class CheckAuthRequested extends AuthEvent {}
class PasswordLoginRequested extends AuthEvent {
final String username;
final String password;
const PasswordLoginRequested({
required this.username,
required this.password,
});
@override
List<Object> get props => [username, password];
}

View File

@@ -4,25 +4,82 @@ abstract class AuthState extends Equatable {
const AuthState();
@override
List<Object> get props => [];
List<Object?> get props => [];
}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthLoading extends AuthState {
final String? message;
const AuthLoading({this.message});
@override
List<Object?> get props => [message];
}
class SignupInProgress extends AuthState {
final String username;
final String email;
final String displayName;
const SignupInProgress({
required this.username,
required this.email,
required this.displayName,
});
@override
List<Object> get props => [username, email, displayName];
}
class RegistrationChallengeReceived extends AuthState {
final String userId;
final String challenge;
final String rpName;
final String rpId;
const RegistrationChallengeReceived({
required this.userId,
required this.challenge,
required this.rpName,
required this.rpId,
});
@override
List<Object> get props => [userId, challenge, rpName, rpId];
}
class AuthenticationChallengeReceived extends AuthState {
final String challenge;
final List<String> credentialIds;
const AuthenticationChallengeReceived({
required this.challenge,
required this.credentialIds,
});
@override
List<Object> get props => [challenge, credentialIds];
}
class AuthAuthenticated extends AuthState {
final String token;
final String userId;
final String username;
final String email;
const AuthAuthenticated({required this.token, required this.userId});
const AuthAuthenticated({
required this.token,
required this.userId,
required this.username,
required this.email,
});
@override
List<Object> get props => [token, userId];
List<Object> get props => [token, userId, username, email];
}
class AuthUnauthenticated extends AuthState {}
class AuthFailure extends AuthState {
final String error;
@@ -31,3 +88,10 @@ class AuthFailure extends AuthState {
@override
List<Object> get props => [error];
}
class AuthUnauthenticated extends AuthState {
const AuthUnauthenticated();
@override
List<Object> get props => [];
}

View File

@@ -3,18 +3,12 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'blocs/auth/auth_bloc.dart';
import 'blocs/session/session_bloc.dart';
import 'blocs/organization/organization_bloc.dart';
import 'blocs/permission/permission_bloc.dart';
import 'blocs/file_browser/file_browser_bloc.dart';
import 'blocs/upload/upload_bloc.dart';
import 'blocs/activity/activity_bloc.dart';
import 'repositories/mock_file_repository.dart';
import 'services/file_service.dart';
import 'services/api_client.dart';
import 'services/org_api.dart';
import 'services/activity_api.dart';
import 'pages/home_page.dart';
import 'pages/login_form.dart';
import 'pages/signup_form.dart';
import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart';
import 'pages/editor_page.dart';
@@ -24,6 +18,7 @@ final GoRouter _router = GoRouter(
routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginForm()),
GoRoute(path: '/signup', builder: (context, state) => const SignupForm()),
GoRoute(
path: '/viewer/:orgId/:fileId',
@@ -58,25 +53,11 @@ class MainApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<AuthBloc>(create: (_) => AuthBloc()),
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<PermissionBloc>(create: (_) => PermissionBloc()),
BlocProvider<FileBrowserBloc>(
create: (context) => FileBrowserBloc(
FileService(ApiClient(context.read<SessionBloc>())),
),
),
BlocProvider<UploadBloc>(
create: (_) =>
UploadBloc(MockFileRepository()), // keep mock for upload
),
BlocProvider<OrganizationBloc>(
lazy: true,
create: (context) => OrganizationBloc(
context.read<PermissionBloc>(),
context.read<FileBrowserBloc>(),
context.read<UploadBloc>(),
OrgApi(ApiClient(context.read<SessionBloc>())),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<ActivityBloc>(

View File

@@ -0,0 +1,63 @@
import 'package:equatable/equatable.dart';
class Credential extends Equatable {
final String id;
final String userId;
final String credentialId;
final String publicKey;
final int signCount;
final DateTime createdAt;
final DateTime? lastUsedAt;
final List<String>? transports;
const Credential({
required this.id,
required this.userId,
required this.credentialId,
required this.publicKey,
this.signCount = 0,
required this.createdAt,
this.lastUsedAt,
this.transports,
});
@override
List<Object?> get props => [
id,
userId,
credentialId,
publicKey,
signCount,
createdAt,
lastUsedAt,
transports,
];
factory Credential.fromJson(Map<String, dynamic> json) {
return Credential(
id: json['id'] as String,
userId: json['userId'] as String,
credentialId: json['credentialId'] as String,
publicKey: json['publicKey'] as String,
signCount: json['signCount'] as int? ?? 0,
createdAt: DateTime.parse(json['createdAt'] as String),
lastUsedAt: json['lastUsedAt'] != null
? DateTime.parse(json['lastUsedAt'] as String)
: null,
transports: (json['transports'] as List<dynamic>?)?.cast<String>(),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'credentialId': credentialId,
'publicKey': publicKey,
'signCount': signCount,
'createdAt': createdAt.toIso8601String(),
'lastUsedAt': lastUsedAt?.toIso8601String(),
'transports': transports,
};
}
}

View File

@@ -1,15 +1,73 @@
import 'package:equatable/equatable.dart';
class User extends Equatable {
final String id;
final String username;
final String email;
final String? name;
final String? displayName;
final DateTime createdAt;
final DateTime? lastLoginAt;
const User({required this.email, this.name});
const User({
required this.id,
required this.username,
required this.email,
this.displayName,
required this.createdAt,
this.lastLoginAt,
});
@override
List<Object?> get props => [email, name];
List<Object?> get props => [
id,
username,
email,
displayName,
createdAt,
lastLoginAt,
];
User copyWith({String? email, String? name}) {
return User(email: email ?? this.email, name: name ?? this.name);
User copyWith({
String? id,
String? username,
String? email,
String? displayName,
DateTime? createdAt,
DateTime? lastLoginAt,
}) {
return User(
id: id ?? this.id,
username: username ?? this.username,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
createdAt: createdAt ?? this.createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
);
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'] as String,
username: json['username'] as String,
email: json['email'] as String,
displayName: json['displayName'] as String?,
createdAt: DateTime.parse(
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
),
lastLoginAt: json['lastLoginAt'] != null
? DateTime.parse(json['lastLoginAt'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'username': username,
'email': email,
'displayName': displayName,
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt?.toIso8601String(),
};
}
}

View File

@@ -10,7 +10,7 @@ import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import 'login_form.dart';
import 'login_form.dart' show LoginForm;
import 'file_explorer.dart';
class HomePage extends StatefulWidget {

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
@@ -16,8 +18,55 @@ class LoginForm extends StatefulWidget {
}
class _LoginFormState extends State<LoginForm> {
final _emailController = TextEditingController();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
bool _usePasskey = true;
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
String _generateRandomHex(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _handleAuthentication(
BuildContext context,
AuthenticationChallengeReceived state,
) async {
try {
// Simulate WebAuthn authentication by generating fake assertion data
// In a real implementation, this would call native WebAuthn APIs
final credentialId = state.credentialIds.isNotEmpty
? state.credentialIds.first
: _generateRandomHex(64);
if (context.mounted) {
context.read<AuthBloc>().add(
AuthenticationResponseSubmitted(
username: _usernameController.text,
challenge: state.challenge,
credentialId: credentialId,
authenticatorData: _generateRandomHex(37),
clientDataJSON:
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
signature: _generateRandomHex(128),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Authentication failed: $e')));
}
}
}
@override
Widget build(BuildContext context) {
@@ -27,62 +76,69 @@ class _LoginFormState extends State<LoginForm> {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error)));
} else if (state is AuthenticationChallengeReceived) {
_handleAuthentication(context, state);
} else if (state is AuthAuthenticated) {
// Start session
context.read<SessionBloc>().add(SessionStarted(state.token));
context.go('/');
}
},
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'sign in',
style: TextStyle(fontSize: 24, color: AppTheme.primaryText),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'sign in',
style: TextStyle(fontSize: 24, color: AppTheme.primaryText),
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _usernameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
child: Column(
children: [
TextField(
controller: _emailController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.emailAddress,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'email',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.email_outlined,
color: AppTheme.primaryText,
size: 20,
),
const SizedBox(height: 16),
if (!_usePasskey)
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
style: const TextStyle(color: AppTheme.primaryText),
),
Divider(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
child: TextField(
controller: _passwordController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
obscuringCharacter: '',
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'password',
@@ -90,37 +146,96 @@ class _LoginFormState extends State<LoginForm> {
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline_rounded,
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
if (!_usePasskey) const SizedBox(height: 16),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username is required'),
),
);
return;
}
if (!_usePasskey && _passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password is required'),
),
);
return;
}
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(username: _usernameController.text),
);
} else {
context.read<AuthBloc>().add(
PasswordLoginRequested(
username: _usernameController.text,
password: _passwordController.text,
),
);
}
},
child: const Text('sign in'),
);
},
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () => setState(() => _usePasskey = !_usePasskey),
child: Text(
_usePasskey ? 'use password' : 'use passkey',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
fontSize: 12,
),
),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
context.read<AuthBloc>().add(
LoginRequested(
// _emailController.text,
// _passwordController.text,
),
);
},
child: const Text('sign in'),
);
},
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'don\'t have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => context.go('/signup'),
child: Text(
'create one',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
),
),
),
],
),
),
],
],
),
),
),
),

View File

@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
class SignupForm extends StatefulWidget {
const SignupForm({super.key});
@override
State<SignupForm> createState() => _SignupFormState();
}
class _SignupFormState extends State<SignupForm> {
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
final _displayNameController = TextEditingController();
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
_displayNameController.dispose();
super.dispose();
}
String _generateRandomHex(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
}
Future<void> _handleRegistration(
BuildContext context,
RegistrationChallengeReceived state,
) async {
try {
// Simulate WebAuthn registration by generating fake credential data
// In a real implementation, this would call native WebAuthn APIs
final credentialId = _generateRandomHex(64);
final publicKey = _generateRandomHex(91); // EC2 public key size
if (context.mounted) {
context.read<AuthBloc>().add(
RegistrationResponseSubmitted(
userId: state.userId,
challenge: state.challenge,
credentialId: credentialId,
publicKey: publicKey,
clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128),
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
}
}
}
@override
Widget build(BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthFailure) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error)));
} else if (state is RegistrationChallengeReceived) {
_handleRegistration(context, state);
} else if (state is AuthAuthenticated) {
context.go('/');
}
},
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text(
'create account',
style: TextStyle(fontSize: 24, color: AppTheme.primaryText),
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: Column(
children: [
TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
Divider(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
controller: _passwordController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
Divider(
color: AppTheme.accentColor.withValues(alpha: 0.2),
height: 1,
thickness: 1,
),
TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'display name (optional)',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.badge_outlined,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
],
),
),
const SizedBox(height: 16),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty ||
_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Username and password are required',
),
),
);
return;
}
context.read<AuthBloc>().add(
SignupStarted(
username: _usernameController.text,
email: _usernameController
.text, // Use username as email for now
displayName: _displayNameController.text,
password: _passwordController.text,
),
);
},
child: const Text('create'),
);
},
),
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'already have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () => context.go('/login'),
child: Text(
'sign in',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
),
),
),
],
),
],
),
),
),
),
);
}
}

View File

@@ -6,7 +6,12 @@ class MockAuthRepository implements AuthRepository {
Future<User> login(String email, String password) async {
await Future.delayed(const Duration(seconds: 1));
if (email.isNotEmpty && password.isNotEmpty) {
return User(email: email);
return User(
id: 'mock-user-id',
username: 'mockuser',
email: email,
createdAt: DateTime.now(),
);
} else {
throw Exception('Invalid credentials');
}

View File

@@ -2,7 +2,6 @@ import '../models/file_item.dart';
import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../repositories/file_repository.dart';
import 'api_client.dart';
class FileService {

View File

@@ -37,8 +37,7 @@ dependencies:
logger: ^2.0.2
# Image Handling
cached_network_image:
^3.3.0
cached_network_image: ^3.3.0
# SVG Support
flutter_svg: ^2.0.9