nkbmusic/admin/lib/screens/sign_in_screen.dart
metatroncubeswdev cf645071aa Add NK Admin dashboard (Flutter web) for admin.nkbmusic.com
Email-code sign-in for admins only; Overview with a needs-attention list;
Songs with search, playback, editing, approve/hide and bulk edit; Albums
and Singers with merge; creator applications queue. Adds the admin views,
functions and creator_applications table, a deploy-admin script and a
how-to guide. The admin nginx config no longer caches unhashed JS and
sends its security headers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:35:44 -04:00

161 lines
6.0 KiB
Dart

import 'package:flutter/material.dart';
import '../api.dart';
import '../session.dart';
import '../theme.dart';
import '../widgets.dart';
/// Email-code sign-in. Codes are only sent to existing accounts, and only admins get in.
class SignInScreen extends StatefulWidget {
const SignInScreen({super.key});
@override
State<SignInScreen> createState() => _SignInScreenState();
}
class _SignInScreenState extends State<SignInScreen> {
final _email = TextEditingController();
final _code = TextEditingController();
bool _codeSent = false;
bool _busy = false;
String? _error;
@override
void dispose() {
_email.dispose();
_code.dispose();
super.dispose();
}
String get _emailText => _email.text.trim().toLowerCase();
Future<void> _run(Future<void> Function() job) async {
setState(() {
_busy = true;
_error = null;
});
try {
await job();
} catch (e) {
_error = errorText(e);
} finally {
if (mounted) setState(() => _busy = false);
}
}
void _sendCode() {
if (!_emailText.contains('@')) {
setState(() => _error = 'Enter your admin email address.');
return;
}
_run(() async {
await AdminApi.sendCode(_emailText);
_codeSent = true;
_code.clear();
});
}
void _verify() {
if (_code.text.trim().length < 6) {
setState(() => _error = 'Enter the 6-digit code from the email.');
return;
}
_run(() => AdminApi.verifyCode(_emailText, _code.text.trim()));
}
@override
Widget build(BuildContext context) {
final session = AdminSession.instance;
return Scaffold(
body: ListenableBuilder(
listenable: session,
builder: (context, _) {
if (session.state == SessionState.checking) return const Center(child: CircularProgressIndicator());
final message = _error ?? session.refusal;
return Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
width: 56,
height: 56,
alignment: Alignment.center,
decoration: const BoxDecoration(color: NkColors.sun, shape: BoxShape.circle),
child: const Text('NK',
style: TextStyle(color: NkColors.ground, fontWeight: FontWeight.w900, fontSize: 20)),
),
const SizedBox(height: 20),
Text('Nanga Kathai Admin', style: Theme.of(context).textTheme.headlineSmall),
const SizedBox(height: 6),
Text(
_codeSent
? 'We emailed a 6-digit code to $_emailText.'
: 'Sign in with your admin email. We\'ll send you a code.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 24),
if (!_codeSent)
TextField(
controller: _email,
autofocus: true,
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
decoration: const InputDecoration(labelText: 'Email'),
onSubmitted: (_) => _sendCode(),
)
else
TextField(
controller: _code,
autofocus: true,
keyboardType: TextInputType.number,
autofillHints: const [AutofillHints.oneTimeCode],
maxLength: 6,
style: const TextStyle(fontSize: 22, letterSpacing: 8, fontWeight: FontWeight.w700),
decoration: const InputDecoration(labelText: 'Code', counterText: ''),
onSubmitted: (_) => _verify(),
),
if (message != null) ...[
const SizedBox(height: 12),
Text(message, style: const TextStyle(color: NkColors.roofRed)),
],
const SizedBox(height: 20),
FilledButton(
onPressed: _busy ? null : (_codeSent ? _verify : _sendCode),
child: _busy
? const SizedBox.square(
dimension: 20, child: CircularProgressIndicator(strokeWidth: 2))
: Text(_codeSent ? 'Sign in' : 'Email me a code'),
),
if (_codeSent) ...[
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _busy ? null : () => setState(() => _codeSent = false),
child: const Text('Use another email'),
),
TextButton(onPressed: _busy ? null : _sendCode, child: const Text('Send again')),
],
),
],
],
),
),
),
),
),
);
},
),
);
}
}