1
0
forked from baron/baron-sso
Files
baron-sso/userfront/lib/features/admin/presentation/user_management_screen.dart
2026-02-10 19:13:00 +09:00

451 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'dart:async';
import '../../../../core/services/auth_proxy_service.dart';
class UserManagementScreen extends StatefulWidget {
const UserManagementScreen({super.key});
@override
State<UserManagementScreen> createState() => _UserManagementScreenState();
}
class _UserManagementScreenState extends State<UserManagementScreen> with SingleTickerProviderStateMixin {
late TabController _tabController;
bool _isAuthorized = false;
String? _verifiedAdminPassword;
bool _isLoading = false;
// --- List Tab Variables ---
List<dynamic> _users = [];
final TextEditingController _searchController = TextEditingController();
Timer? _debounce;
// --- Create Tab Controllers ---
final _formKey = GlobalKey<FormState>();
final TextEditingController _createLoginIdController = TextEditingController();
final TextEditingController _createEmailController = TextEditingController();
final TextEditingController _createPhoneController = TextEditingController();
final TextEditingController _createNameController = TextEditingController();
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess());
}
@override
void dispose() {
_tabController.dispose();
_searchController.dispose();
_debounce?.cancel();
_createLoginIdController.dispose();
_createEmailController.dispose();
_createPhoneController.dispose();
_createNameController.dispose();
super.dispose();
}
// --- Authentication ---
Future<void> _verifyAccess() async {
final passwordController = TextEditingController();
final String? inputPassword = await showDialog<String>(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text("Admin Authentication Required"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Please enter the admin password."),
const SizedBox(height: 16),
TextField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: "Password", border: OutlineInputBorder()),
autofocus: true,
onSubmitted: (value) => Navigator.pop(context, value),
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, passwordController.text), child: const Text("Enter")),
],
),
);
if (inputPassword == null || inputPassword.isEmpty) {
if (mounted) context.go('/');
return;
}
setState(() => _isLoading = true);
final isValid = await AuthProxyService.checkAdminAuth(inputPassword);
setState(() => _isLoading = false);
if (isValid) {
if (mounted) {
setState(() {
_isAuthorized = true;
_verifiedAdminPassword = inputPassword;
});
_loadUsers();
}
} else {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red));
context.go('/');
}
}
}
// --- User List Logic ---
Future<void> _loadUsers({String? query}) async {
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
try {
final users = await AuthProxyService.listUsers(_verifiedAdminPassword!, query: query);
setState(() => _users = users);
} catch (e) {
_showError("Failed to load users: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _onSearchChanged(String query) {
if (_debounce?.isActive ?? false) _debounce!.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
_loadUsers(query: query);
});
}
Future<void> _deleteUser(String loginId) async {
if (_verifiedAdminPassword == null) return;
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("Delete User"),
content: Text("Are you sure you want to delete $loginId? This cannot be undone."),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: Colors.red),
onPressed: () => Navigator.pop(context, true),
child: const Text("Delete")
),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
try {
await AuthProxyService.deleteUser(_verifiedAdminPassword!, loginId);
_showSuccess("User deleted");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Failed to delete: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _toggleStatus(String loginId, String currentStatus) async {
if (_verifiedAdminPassword == null) return;
final newStatus = (currentStatus == "enabled" || currentStatus == "active") ? "disabled" : "enabled";
setState(() => _isLoading = true);
try {
await AuthProxyService.updateUserStatus(_verifiedAdminPassword!, loginId, newStatus);
_showSuccess("User status updated to $newStatus");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Failed to update status: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
Future<void> _editUser(Map user) async {
if (_verifiedAdminPassword == null) return;
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "";
if (loginId.isEmpty) return;
final nameController = TextEditingController(text: user['name'] ?? user['user']?['name'] ?? "");
final emailController = TextEditingController(text: user['user']?['email'] ?? "");
final phoneController = TextEditingController(text: user['user']?['phone'] ?? "");
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text("Edit User: $loginId"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(controller: nameController, decoration: const InputDecoration(labelText: "Name")),
TextField(controller: emailController, decoration: const InputDecoration(labelText: "Email")),
TextField(controller: phoneController, decoration: const InputDecoration(labelText: "Phone")),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("Cancel")),
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text("Save")),
],
),
);
if (confirm != true) return;
setState(() => _isLoading = true);
String? phone = phoneController.text.trim().isEmpty ? null : phoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
phone = '+82${phone.substring(1)}';
}
}
try {
await AuthProxyService.updateUserDetails(
adminPassword: _verifiedAdminPassword!,
loginId: loginId,
displayName: nameController.text.trim(),
email: emailController.text.trim(),
phone: phone,
);
_showSuccess("User updated successfully");
_loadUsers(query: _searchController.text);
} catch (e) {
_showError("Update failed: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// --- Create User Logic ---
Future<void> _createUserSubmit() async {
if (!_formKey.currentState!.validate()) return;
if (_verifiedAdminPassword == null) return;
setState(() => _isLoading = true);
String loginId = _createLoginIdController.text.trim();
if (!loginId.contains('@')) {
loginId = loginId.replaceAll(RegExp(r'[-\s]'), '');
if (loginId.startsWith('010')) {
loginId = '+82${loginId.substring(1)}';
}
}
String? phone = _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim();
if (phone != null && !phone.contains('@')) {
phone = phone.replaceAll(RegExp(r'[-\s]'), '');
if (phone.startsWith('010')) {
phone = '+82${phone.substring(1)}';
}
}
try {
await AuthProxyService.createUser(
loginId: loginId,
adminPassword: _verifiedAdminPassword!,
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
phone: phone,
displayName: _createNameController.text.trim().isEmpty ? null : _createNameController.text.trim(),
);
_showSuccess("User created successfully");
_formKey.currentState!.reset();
_createLoginIdController.clear();
_createEmailController.clear();
_createPhoneController.clear();
_createNameController.clear();
// Switch to list tab and reload
_tabController.animateTo(0);
_loadUsers();
} catch (e) {
_showError("Error: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
// --- UI Helpers ---
void _showError(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red));
}
void _showSuccess(String msg) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.green));
}
@override
Widget build(BuildContext context) {
if (!_isAuthorized) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: const Text('User Management'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.go('/'),
),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(icon: Icon(Icons.list), text: "User List"),
Tab(icon: Icon(Icons.person_add), text: "Create User"),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildUserListTab(),
_buildCreateUserTab(),
],
),
);
}
Widget _buildUserListTab() {
return Column(
children: [
if (_isLoading) const LinearProgressIndicator(),
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: const InputDecoration(
labelText: "Search Users",
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(),
hintText: "Email, Phone, or Name",
),
onChanged: _onSearchChanged,
),
),
Expanded(
child: _users.isEmpty
? const Center(child: Text("No users found."))
: ListView.separated(
itemCount: _users.length,
separatorBuilder: (context, index) => const Divider(),
itemBuilder: (context, index) {
final user = _users[index];
// 일부 응답은 최상위 또는 user 하위에 필드를 포함합니다.
final loginIDs = (user['loginIds'] as List?) ?? [];
final loginId = loginIDs.isNotEmpty ? loginIDs.first.toString() : "Unknown ID";
final name = user['name'] ?? user['user']?['name'] ?? "No Name";
final status = user['status'] ?? "unknown";
final isEnabled = status == "enabled" || status == "active";
return ListTile(
leading: CircleAvatar(
backgroundColor: isEnabled ? Colors.green.shade100 : Colors.grey.shade300,
child: Icon(Icons.person, color: isEnabled ? Colors.green : Colors.grey),
),
title: Text(name),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loginId, style: const TextStyle(fontWeight: FontWeight.bold)),
Text("Status: $status", style: TextStyle(color: isEnabled ? Colors.green : Colors.red, fontSize: 12)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
tooltip: "Edit User",
onPressed: () => _editUser(user),
),
IconButton(
icon: Icon(isEnabled ? Icons.block : Icons.check_circle, color: isEnabled ? Colors.orange : Colors.green),
tooltip: isEnabled ? "Disable User" : "Enable User",
onPressed: () => _toggleStatus(loginId, status),
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
tooltip: "Delete User",
onPressed: () => _deleteUser(loginId),
),
],
),
);
},
),
),
],
);
}
Widget _buildCreateUserTab() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_isLoading) const LinearProgressIndicator(),
const SizedBox(height: 20),
TextFormField(
controller: _createLoginIdController,
decoration: const InputDecoration(
labelText: "Login ID (Required)",
border: OutlineInputBorder(),
helperText: "Unique identifier (Email or Phone)",
),
validator: (value) => value == null || value.isEmpty ? 'Please enter Login ID' : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _createNameController,
decoration: const InputDecoration(labelText: "Display Name", border: OutlineInputBorder(), prefixIcon: Icon(Icons.person)),
),
const SizedBox(height: 16),
TextFormField(
controller: _createEmailController,
decoration: const InputDecoration(labelText: "Email", border: OutlineInputBorder(), prefixIcon: Icon(Icons.email)),
),
const SizedBox(height: 16),
TextFormField(
controller: _createPhoneController,
decoration: const InputDecoration(labelText: "Phone Number", border: OutlineInputBorder(), prefixIcon: Icon(Icons.phone), helperText: "010-xxxx-xxxx"),
),
const SizedBox(height: 32),
FilledButton(
onPressed: _isLoading ? null : _createUserSubmit,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text("Create User"),
),
],
),
),
),
),
);
}
}