diff --git a/integration_tests/test/auth_test.dart b/integration_tests/test/auth_test.dart index 2407305..d11194d 100644 --- a/integration_tests/test/auth_test.dart +++ b/integration_tests/test/auth_test.dart @@ -95,6 +95,36 @@ void main() { throwsA(isA()), ); }); + + // Email-verification (code) flow used by the sample's registration UI. + // We can't read the emailed code here, so we assert the SDK plumbing: + // a resend is accepted and a wrong code is rejected with a typed error. + test('verification code flow: resend is accepted, wrong code rejected', + () async { + final auth = _newAuth(); + final email = env.uniqueEmail('verify'); + final res = await auth.signUp(email: email, password: testPassword); + + if (res.hasSession) { + // Verification is disabled on this project — nothing to verify. + return; + } + expect(res.requireEmailVerification, isTrue); + + // Resend the code. Success or a structured backend response (e.g. a + // rate-limit) is fine; a transport/parse failure is not. + try { + await auth.sendVerificationEmail(email); + } on InsforgeHttpException { + // Acceptable (e.g. throttled). + } + + // An obviously-wrong 6-digit code must be rejected with a typed error. + await expectLater( + auth.verifyEmail(email: email, otp: '000000'), + throwsA(isA()), + ); + }); }, skip: env.coreSkipReason, ); diff --git a/samples/twitter_app/lib/screens/auth_screen.dart b/samples/twitter_app/lib/screens/auth_screen.dart index f22a4c0..f8094f2 100644 --- a/samples/twitter_app/lib/screens/auth_screen.dart +++ b/samples/twitter_app/lib/screens/auth_screen.dart @@ -15,15 +15,23 @@ class AuthScreen extends ConsumerStatefulWidget { class _AuthScreenState extends ConsumerState { final _email = TextEditingController(); final _password = TextEditingController(); + final _code = TextEditingController(); bool _isSignUp = false; bool _busy = false; String? _error; String? _info; + /// When true, the user has signed up and must enter the emailed verification + /// code before a session is established. [_pendingEmail] is the address the + /// code was sent to. + bool _awaitingCode = false; + String _pendingEmail = ''; + @override void dispose() { _email.dispose(); _password.dispose(); + _code.dispose(); super.dispose(); } @@ -34,22 +42,87 @@ class _AuthScreenState extends ConsumerState { _info = null; }); final auth = ref.read(authClientProvider); + final email = _email.text.trim(); try { if (_isSignUp) { - final res = await auth.signUp( - email: _email.text.trim(), - password: _password.text, - ); - if (!res.hasSession) { - setState( - () => _info = - 'Check your email to verify your account, then sign in.', - ); + final res = await auth.signUp(email: email, password: _password.text); + if (res.hasSession) { + // Verification disabled — the authStateProvider stream flips the gate. + return; } + // Verification required: move to the code-entry step. Sign-up already + // emailed a 6-digit code, so no need to resend here. + await _startVerification(email, resend: false); + } else { + await auth.signIn(email: email, password: _password.text); + // On success the authStateProvider stream flips the gate automatically. + } + } on InsforgeHttpException catch (e) { + // A returning user whose email is still unverified gets a 403 here — + // route them into the verification step instead of a dead-end error. + if (!_isSignUp && _isEmailUnverified(e)) { + await _startVerification(email, resend: true); } else { - await auth.signIn(email: _email.text.trim(), password: _password.text); + setState(() => _error = e.message); + } + } catch (e) { + setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + /// The backend returns 403 "Email verification required" when an unverified + /// account tries to sign in. + bool _isEmailUnverified(InsforgeHttpException e) => + e.statusCode == 403 && e.message.toLowerCase().contains('verif'); + + /// Switches to the code-entry view for [email]. When [resend] is true (the + /// returning-user path, where the original code has likely expired) a fresh + /// code is requested. + Future _startVerification(String email, {required bool resend}) async { + setState(() { + _awaitingCode = true; + _pendingEmail = email; + _error = null; + _info = resend + ? "Your email isn't verified yet — sending a new code…" + : 'We sent a 6-digit code to $email. Enter it below.'; + }); + if (!resend) return; + try { + await ref.read(authClientProvider).sendVerificationEmail(email); + if (mounted) { + setState( + () => _info = 'We sent a 6-digit code to $email. Enter it below.', + ); } - // On success the authStateProvider stream flips the gate automatically. + } on InsforgeHttpException catch (e) { + if (mounted) { + setState( + () => _info = 'Enter the code from your email, or tap Resend. ' + '(${e.message})', + ); + } + } + } + + Future _verifyCode() async { + final code = _code.text.trim(); + if (code.isEmpty) { + setState(() => _error = 'Enter the code from your email.'); + return; + } + setState(() { + _busy = true; + _error = null; + _info = null; + }); + final auth = ref.read(authClientProvider); + try { + // On success this establishes a session and emits signedIn, so the + // authStateProvider stream flips the gate — no manual navigation needed. + await auth.verifyEmail(email: _pendingEmail, otp: code); } on InsforgeHttpException catch (e) { setState(() => _error = e.message); } catch (e) { @@ -59,6 +132,34 @@ class _AuthScreenState extends ConsumerState { } } + Future _resendCode() async { + setState(() { + _busy = true; + _error = null; + _info = null; + }); + final auth = ref.read(authClientProvider); + try { + await auth.sendVerificationEmail(_pendingEmail); + setState(() => _info = 'A new code is on its way to $_pendingEmail.'); + } on InsforgeHttpException catch (e) { + setState(() => _error = e.message); + } catch (e) { + setState(() => _error = '$e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + void _cancelVerification() { + setState(() { + _awaitingCode = false; + _code.clear(); + _error = null; + _info = null; + }); + } + Future _oauth(OAuthProvider provider) async { setState(() { _busy = true; @@ -82,77 +183,128 @@ class _AuthScreenState extends ConsumerState { padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 400), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _isSignUp ? 'Create account' : 'Sign in', - style: Theme.of(context).textTheme.headlineSmall, - ), - const SizedBox(height: 16), - TextField( - controller: _email, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration(labelText: 'Email'), - ), - const SizedBox(height: 8), - TextField( - controller: _password, - obscureText: true, - decoration: const InputDecoration(labelText: 'Password'), - ), - const SizedBox(height: 16), - if (_error != null) - Text(_error!, style: const TextStyle(color: Colors.red)), - if (_info != null) - Text(_info!, style: const TextStyle(color: Colors.green)), - const SizedBox(height: 8), - FilledButton( - onPressed: _busy ? null : _submit, - child: _busy - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(_isSignUp ? 'Sign up' : 'Sign in'), - ), - TextButton( - onPressed: _busy - ? null - : () => setState(() => _isSignUp = !_isSignUp), - child: Text( - _isSignUp - ? 'Have an account? Sign in' - : 'New here? Create an account', - ), - ), - const Divider(height: 32), - const Text('Or continue with'), - const SizedBox(height: 8), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - OutlinedButton.icon( - onPressed: - _busy ? null : () => _oauth(OAuthProvider.google), - icon: const Icon(Icons.g_mobiledata), - label: const Text('Google'), - ), - const SizedBox(width: 12), - OutlinedButton.icon( - onPressed: - _busy ? null : () => _oauth(OAuthProvider.github), - icon: const Icon(Icons.code), - label: const Text('GitHub'), - ), - ], - ), - ], - ), + child: _awaitingCode + ? _buildVerifyForm(context) + : _buildAuthForm(context), ), ), ), ); } + + Widget _buildAuthForm(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _isSignUp ? 'Create account' : 'Sign in', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + TextField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + ), + const SizedBox(height: 8), + TextField( + controller: _password, + obscureText: true, + decoration: const InputDecoration(labelText: 'Password'), + ), + const SizedBox(height: 16), + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.red)), + if (_info != null) + Text(_info!, style: const TextStyle(color: Colors.green)), + const SizedBox(height: 8), + FilledButton( + onPressed: _busy ? null : _submit, + child: _busy + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isSignUp ? 'Sign up' : 'Sign in'), + ), + TextButton( + onPressed: + _busy ? null : () => setState(() => _isSignUp = !_isSignUp), + child: Text( + _isSignUp + ? 'Have an account? Sign in' + : 'New here? Create an account', + ), + ), + const Divider(height: 32), + const Text('Or continue with'), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _busy ? null : () => _oauth(OAuthProvider.google), + icon: const Icon(Icons.g_mobiledata), + label: const Text('Google'), + ), + const SizedBox(width: 12), + OutlinedButton.icon( + onPressed: _busy ? null : () => _oauth(OAuthProvider.github), + icon: const Icon(Icons.code), + label: const Text('GitHub'), + ), + ], + ), + ], + ); + } + + Widget _buildVerifyForm(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Verify your email', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + TextField( + controller: _code, + keyboardType: TextInputType.number, + maxLength: 6, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24, letterSpacing: 8), + decoration: const InputDecoration( + labelText: '6-digit code', + counterText: '', + ), + ), + const SizedBox(height: 16), + if (_error != null) + Text(_error!, style: const TextStyle(color: Colors.red)), + if (_info != null) + Text(_info!, style: const TextStyle(color: Colors.green)), + const SizedBox(height: 8), + FilledButton( + onPressed: _busy ? null : _verifyCode, + child: _busy + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Verify'), + ), + TextButton( + onPressed: _busy ? null : _resendCode, + child: const Text('Resend code'), + ), + TextButton( + onPressed: _busy ? null : _cancelVerification, + child: const Text('Back'), + ), + ], + ); + } } diff --git a/samples/twitter_app/lib/screens/compose_screen.dart b/samples/twitter_app/lib/screens/compose_screen.dart index 09e95af..6a95b50 100644 --- a/samples/twitter_app/lib/screens/compose_screen.dart +++ b/samples/twitter_app/lib/screens/compose_screen.dart @@ -83,6 +83,18 @@ class _ComposeScreenState extends ConsumerState { }); final client = ref.read(insforgeClientProvider); try { + // `tweets.user_id` references `profiles(id)`, but InsForge does not + // auto-create a profiles row on signup. Ensure one exists for the + // current user (idempotent) before posting, so the FK is satisfied and + // the feed's author join has a name to show. + await client.database.from('profiles').upsert( + { + 'id': user.id, + 'name': user.name ?? user.email, + }, + onConflict: 'id', + ).execute(); + String? imageUrl; if (_imageBytes != null) { final stored = await client.storage diff --git a/samples/twitter_app/lib/screens/profile_screen.dart b/samples/twitter_app/lib/screens/profile_screen.dart index 16cf86c..6b556cc 100644 --- a/samples/twitter_app/lib/screens/profile_screen.dart +++ b/samples/twitter_app/lib/screens/profile_screen.dart @@ -33,6 +33,12 @@ class _ProfileScreenState extends ConsumerState { super.dispose(); } + // This screen reads and writes the `profiles` records table — the same + // table the feed joins for author name/avatar and that Compose upserts a row + // into. (InsForge also has a separate auth-side profile via + // auth.getProfile/updateProfile; the sample standardizes on the `profiles` + // table so edits are visible there and reflected in the feed.) + Future _load() async { final user = ref.read(currentUserProvider); if (user == null) { @@ -40,9 +46,21 @@ class _ProfileScreenState extends ConsumerState { return; } try { - final profile = await ref.read(authClientProvider).getProfile(user.id); - _name.text = profile.profile['name'] as String? ?? ''; - _bio.text = profile.profile['bio'] as String? ?? ''; + final rows = await ref + .read(insforgeClientProvider) + .database + .from('profiles') + .select('name,bio') + .eq('id', user.id) + .limit(1) + .execute(); + if (rows.isNotEmpty) { + _name.text = rows.first['name'] as String? ?? ''; + _bio.text = rows.first['bio'] as String? ?? ''; + } else { + // No profiles row yet — seed the name from the auth user. + _name.text = user.name ?? ''; + } } on InsforgeException catch (e) { _error = e.message; } finally { @@ -51,16 +69,22 @@ class _ProfileScreenState extends ConsumerState { } Future _save() async { + final user = ref.read(currentUserProvider); + if (user == null) return; setState(() { _saving = true; _error = null; _info = null; }); try { - await ref.read(authClientProvider).updateProfile({ - 'name': _name.text.trim(), - 'bio': _bio.text.trim(), - }); + await ref.read(insforgeClientProvider).database.from('profiles').upsert( + { + 'id': user.id, + 'name': _name.text.trim(), + 'bio': _bio.text.trim(), + }, + onConflict: 'id', + ).execute(); setState(() => _info = 'Profile saved.'); } on InsforgeException catch (e) { setState(() => _error = e.message);