summaryrefslogtreecommitdiff
path: root/player-android/lib/screens/bootstrap_screen.dart
blob: 370f906d421ddd6be5606b12319da1a478edcf14 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../providers/api_client_provider.dart';
import '../providers/auth_state_provider.dart';
import '../utils/error_mappers.dart';

// Minimum password length enforced by the server (see handlers_auth.go,
// service/auth.go — passwords shorter than this are rejected with 400).
const _kMinPasswordLength = 8;

/// First-run setup screen that creates the initial admin account.
///
/// Displayed when no users exist on the server.  Submitting the form calls
/// POST /api/auth/bootstrap; on success the auth state transitions to
/// [AuthStatus.authenticated] and the router redirects to [AppRoutes.home].
///
/// Design notes:
///   - All mutable state lives in [_BootstrapFormState]; the outer widget is a
///     lightweight [ConsumerStatefulWidget] that owns the Riverpod [Ref].
///   - Validation is client-side only (password length, match check).  Server
///     errors (e.g. 403 "already bootstrapped") are surfaced via a snack-bar.
///   - [ConsumerStatefulWidget] is used instead of [StatelessWidget] so that
///     [WidgetRef] is available across the async submit path without storing it
///     as a field (which would risk using a stale ref after disposal).
class BootstrapScreen extends ConsumerStatefulWidget {
  const BootstrapScreen({super.key});

  @override
  ConsumerState<BootstrapScreen> createState() => _BootstrapScreenState();
}

class _BootstrapScreenState extends ConsumerState<BootstrapScreen> {
  // Form key used to trigger programmatic validation across all fields.
  final _formKey = GlobalKey<FormState>();

  // Controllers for the three text fields.  Disposed in [dispose] to avoid
  // memory leaks when the widget is removed from the tree.
  final _usernameController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmController = TextEditingController();

  // Whether an HTTP request is in flight; drives loading indicator visibility
  // and disables the submit button to prevent double-submission.
  bool _isLoading = false;

  @override
  void dispose() {
    _usernameController.dispose();
    _passwordController.dispose();
    _confirmController.dispose();
    super.dispose();
  }

  // ---------------------------------------------------------------------------
  // Validation helpers
  // ---------------------------------------------------------------------------

  /// Returns an error string if [value] is empty or null; null otherwise.
  String? _validateRequired(String? value) {
    if (value == null || value.trim().isEmpty) {
      return 'This field is required.';
    }
    return null;
  }

  /// Validates the password field: must meet minimum-length policy.
  String? _validatePassword(String? value) {
    final required = _validateRequired(value);
    if (required != null) return required;

    if (value!.length < _kMinPasswordLength) {
      return 'Password must be at least $_kMinPasswordLength characters.';
    }
    return null;
  }

  /// Validates the confirm-password field: must match the password field.
  String? _validateConfirm(String? value) {
    final required = _validateRequired(value);
    if (required != null) return required;

    if (value != _passwordController.text) {
      return 'Passwords do not match.';
    }
    return null;
  }

  // ---------------------------------------------------------------------------
  // Submit logic
  // ---------------------------------------------------------------------------

  /// Validates the form and submits the bootstrap request.
  ///
  /// On success, persists the authenticated session via [AuthStateNotifier.login]
  /// and lets go_router's redirect logic navigate to [AppRoutes.home].
  ///
  /// On server error, extracts a human-readable message from the [DioException]
  /// (status code + response body) and shows it in a [SnackBar].
  Future<void> _submit() async {
    // Client-side validation: abort if any field is invalid.
    if (!(_formKey.currentState?.validate() ?? false)) return;

    setState(() => _isLoading = true);

    try {
      final apiClient = ref.read(apiClientProvider);
      final username = _usernameController.text.trim();
      final password = _passwordController.text;

      // Call POST /api/v1/auth/bootstrap.  On success the server creates the
      // admin user and sets a session cookie.  The returned User confirms the
      // account was created; we use its username as the session marker stored
      // in TokenStorage so that [AuthStateNotifier.build] recognises a prior
      // successful login on the next app start.
      //
      // NOTE: The server uses cookie-based session auth.  For a full mobile
      // bearer-token flow a follow-up task should call createAPIToken after
      // bootstrap and persist that token instead.
      final user = await apiClient.bootstrap(
        username: username,
        password: password,
      );

      // Persist the session marker and update auth state → router redirects
      // automatically to AppRoutes.home via the refreshListenable.
      await ref
          .read(authStateProvider.notifier)
          .login(user.username);
    } on DioException catch (e) {
      // Only show the snack-bar if the widget is still mounted; async gaps can
      // occur between the await above and this error handler.
      if (!mounted) return;
      // Delegate to the shared error mapper with bootstrap-specific status fallbacks.
      _showError(dioErrorMessage(e, statusFallbacks: {
        403: 'Bootstrap already complete — an admin account already exists.',
        400: 'Invalid request. Check your username and password.',
      }));
    } catch (e) {
      if (!mounted) return;
      _showError('An unexpected error occurred. Please try again.');
    } finally {
      // Guard against calling setState on a disposed widget (e.g. if the error
      // path triggers navigation and dispose runs before finally executes).
      if (mounted) {
        setState(() => _isLoading = false);
      }
    }
  }

  /// Shows [message] in a [SnackBar] anchored to the nearest [Scaffold].
  void _showError(String message) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Theme.of(context).colorScheme.error,
      ),
    );
  }

  // ---------------------------------------------------------------------------
  // Build
  // ---------------------------------------------------------------------------

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Set Up Admin Account')),
      body: SafeArea(
        child: SingleChildScrollView(
          padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 32),
          child: Form(
            key: _formKey,
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.stretch,
              children: [
                // Introductory copy explaining the one-time setup context.
                Text(
                  'Welcome to Player',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 8),
                Text(
                  'No accounts exist yet. Create the initial admin account '
                  'to get started.',
                  style: Theme.of(context).textTheme.bodyMedium,
                ),
                const SizedBox(height: 32),

                // Username field.
                TextFormField(
                  key: const Key('bootstrap_username'),
                  controller: _usernameController,
                  decoration: const InputDecoration(
                    labelText: 'Username',
                    border: OutlineInputBorder(),
                  ),
                  textInputAction: TextInputAction.next,
                  autocorrect: false,
                  validator: _validateRequired,
                ),
                const SizedBox(height: 16),

                // Password field (obscured, minimum-length validated).
                TextFormField(
                  key: const Key('bootstrap_password'),
                  controller: _passwordController,
                  decoration: const InputDecoration(
                    labelText: 'Password',
                    border: OutlineInputBorder(),
                    helperText:
                        'Minimum $_kMinPasswordLength characters.',
                  ),
                  obscureText: true,
                  textInputAction: TextInputAction.next,
                  validator: _validatePassword,
                ),
                const SizedBox(height: 16),

                // Confirm-password field (must match password field).
                TextFormField(
                  key: const Key('bootstrap_confirm'),
                  controller: _confirmController,
                  decoration: const InputDecoration(
                    labelText: 'Confirm Password',
                    border: OutlineInputBorder(),
                  ),
                  obscureText: true,
                  textInputAction: TextInputAction.done,
                  onFieldSubmitted: (_) => _isLoading ? null : _submit(),
                  validator: _validateConfirm,
                ),
                const SizedBox(height: 32),

                // Submit button: replaced by a progress indicator while loading.
                _isLoading
                    ? const Center(child: CircularProgressIndicator())
                    : ElevatedButton(
                        key: const Key('bootstrap_submit'),
                        onPressed: _submit,
                        child: const Text('Create Admin Account'),
                      ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// File-level helpers
// ---------------------------------------------------------------------------
//
// [_dioErrorMessage] has been removed; error mapping is now delegated to
// [dioErrorMessage] from ../utils/error_mappers.dart with bootstrap-specific
// status fallbacks supplied at the call site (DRY/DIP fix).