forked from baron/baron-sso
admin management 기능 추가
This commit is contained in:
@@ -137,6 +137,10 @@ func main() {
|
|||||||
admin := api.Group("/admin")
|
admin := api.Group("/admin")
|
||||||
admin.Post("/users", adminHandler.CreateUser)
|
admin.Post("/users", adminHandler.CreateUser)
|
||||||
admin.Get("/check", adminHandler.CheckAuth)
|
admin.Get("/check", adminHandler.CheckAuth)
|
||||||
|
admin.Get("/users", adminHandler.ListUsers)
|
||||||
|
admin.Patch("/users/:loginId", adminHandler.UpdateUser)
|
||||||
|
admin.Delete("/users/:loginId", adminHandler.DeleteUser)
|
||||||
|
admin.Patch("/users/:loginId/status", adminHandler.UpdateUserStatus)
|
||||||
|
|
||||||
// Webhook for Descope Generic SMS Gateway
|
// Webhook for Descope Generic SMS Gateway
|
||||||
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
"github.com/descope/go-sdk/descope"
|
"github.com/descope/go-sdk/descope"
|
||||||
"github.com/descope/go-sdk/descope/client"
|
"github.com/descope/go-sdk/descope/client"
|
||||||
@@ -43,17 +44,8 @@ func boolPtr(b bool) *bool {
|
|||||||
return &b
|
return &b
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateUserRequest struct {
|
// checkAuth Helper
|
||||||
LoginID string `json:"loginId"`
|
func (h *AdminHandler) checkAuth(c *fiber.Ctx) error {
|
||||||
Email string `json:"email"`
|
|
||||||
Phone string `json:"phone"`
|
|
||||||
DisplayName string `json:"displayName"`
|
|
||||||
Roles []string `json:"roles"`
|
|
||||||
Tenants map[string][]string `json:"tenants"` // tenantId -> roles
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
|
||||||
// 1. Simple Password Check
|
|
||||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
adminPass := os.Getenv("ADMIN_PASSWORD")
|
||||||
if adminPass == "" {
|
if adminPass == "" {
|
||||||
adminPass = "admin" // Default fallback
|
adminPass = "admin" // Default fallback
|
||||||
@@ -63,6 +55,165 @@ func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
if reqPass != adminPass {
|
if reqPass != adminPass {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserRequest struct {
|
||||||
|
LoginID string `json:"loginId"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
Roles []string `json:"roles"`
|
||||||
|
Tenants map[string][]string `json:"tenants"` // tenantId -> roles
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUsers - GET /api/v1/admin/users
|
||||||
|
func (h *AdminHandler) ListUsers(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil { return err }
|
||||||
|
|
||||||
|
text := c.Query("text")
|
||||||
|
// Limit is not directly supported in SearchAll options as a simple int in all SDK versions,
|
||||||
|
// but let's check the options struct.
|
||||||
|
// Based on previous inspection: SearchAll takes UserSearchOptions.
|
||||||
|
|
||||||
|
var users []*descope.UserResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if text != "" {
|
||||||
|
options := &descope.UserSearchOptions{ Text: text, Limit: 50 }
|
||||||
|
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), options)
|
||||||
|
} else {
|
||||||
|
// Nil options means default search (usually returns all or default page)
|
||||||
|
users, _, err = h.DescopeClient.Management.User().SearchAll(context.Background(), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Admin] ListUsers failed: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"users": users})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser - DELETE /api/v1/admin/users/:loginId
|
||||||
|
func (h *AdminHandler) DeleteUser(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil { return err }
|
||||||
|
|
||||||
|
loginID := c.Params("loginId")
|
||||||
|
// Decode if necessary (Fiber usually decodes params, but let's be safe if it's double encoded)
|
||||||
|
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||||
|
loginID = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[Admin] Deleting user: %s", loginID)
|
||||||
|
if err := h.DescopeClient.Management.User().Delete(context.Background(), loginID); err != nil {
|
||||||
|
log.Printf("[Admin] DeleteUser failed: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "User deleted successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserStatus - PATCH /api/v1/admin/users/:loginId/status
|
||||||
|
func (h *AdminHandler) UpdateUserStatus(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil { return err }
|
||||||
|
|
||||||
|
loginID := c.Params("loginId")
|
||||||
|
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||||
|
loginID = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Status string `json:"status"` // "enabled" or "disabled"
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
var user *descope.UserResponse
|
||||||
|
var err error
|
||||||
|
|
||||||
|
log.Printf("[Admin] Updating status for %s to %s", loginID, req.Status)
|
||||||
|
|
||||||
|
if req.Status == "enabled" || req.Status == "active" {
|
||||||
|
user, err = h.DescopeClient.Management.User().Activate(context.Background(), loginID)
|
||||||
|
} else {
|
||||||
|
user, err = h.DescopeClient.Management.User().Deactivate(context.Background(), loginID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Admin] Status update failed: %v", err)
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"message": "Status updated",
|
||||||
|
"user": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser - PATCH /api/v1/admin/users/:loginId
|
||||||
|
func (h *AdminHandler) UpdateUser(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil { return err }
|
||||||
|
|
||||||
|
loginID := c.Params("loginId")
|
||||||
|
if decoded, err := url.QueryUnescape(loginID); err == nil {
|
||||||
|
loginID = decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
Email *string `json:"email"`
|
||||||
|
Phone *string `json:"phone"`
|
||||||
|
DisplayName *string `json:"displayName"`
|
||||||
|
}
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid body"})
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// Update Display Name
|
||||||
|
if req.DisplayName != nil {
|
||||||
|
_, err = h.DescopeClient.Management.User().UpdateDisplayName(ctx, loginID, *req.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to update name: " + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Email
|
||||||
|
if req.Email != nil {
|
||||||
|
_, err = h.DescopeClient.Management.User().UpdateEmail(ctx, loginID, *req.Email, true, false) // verified=true, addToLoginIDs=false
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to update email: " + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Phone
|
||||||
|
if req.Phone != nil {
|
||||||
|
phone := *req.Phone
|
||||||
|
// Normalize
|
||||||
|
if strings.HasPrefix(phone, "010") {
|
||||||
|
phone = "+82" + phone[1:]
|
||||||
|
}
|
||||||
|
_, err = h.DescopeClient.Management.User().UpdatePhone(ctx, loginID, phone, true, false) // verified=true, addToLoginIDs=false
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to update phone: " + err.Error()})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"message": "User updated successfully"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
||||||
|
if err := h.checkAuth(c); err != nil { return err }
|
||||||
|
|
||||||
if h.DescopeClient == nil {
|
if h.DescopeClient == nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"})
|
||||||
@@ -128,17 +279,3 @@ func (h *AdminHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
"user": res,
|
"user": res,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AdminHandler) CheckAuth(c *fiber.Ctx) error {
|
|
||||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
|
||||||
if adminPass == "" {
|
|
||||||
adminPass = "admin"
|
|
||||||
}
|
|
||||||
|
|
||||||
reqPass := c.Get("X-Admin-Password")
|
|
||||||
if reqPass != adminPass {
|
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"})
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.Status(fiber.StatusOK).JSON(fiber.Map{"status": "ok"})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -143,6 +143,92 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<List<dynamic>> listUsers(String adminPassword, {String? query}) async {
|
||||||
|
var uri = Uri.parse('$_baseUrl/api/v1/admin/users');
|
||||||
|
if (query != null && query.isNotEmpty) {
|
||||||
|
uri = uri.replace(queryParameters: {'text': query});
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http.get(
|
||||||
|
uri,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Password': adminPassword,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final data = jsonDecode(response.body);
|
||||||
|
return data['users'] ?? [];
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to list users: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> deleteUser(String adminPassword, String loginId) async {
|
||||||
|
final encodedId = Uri.encodeComponent(loginId);
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
|
||||||
|
|
||||||
|
final response = await http.delete(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Password': adminPassword,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to delete user: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateUserStatus(String adminPassword, String loginId, String status) async {
|
||||||
|
final encodedId = Uri.encodeComponent(loginId);
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId/status');
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Password': adminPassword,
|
||||||
|
},
|
||||||
|
body: jsonEncode({'status': status}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to update status: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> updateUserDetails({
|
||||||
|
required String adminPassword,
|
||||||
|
required String loginId,
|
||||||
|
String? email,
|
||||||
|
String? phone,
|
||||||
|
String? displayName,
|
||||||
|
}) async {
|
||||||
|
final encodedId = Uri.encodeComponent(loginId);
|
||||||
|
final url = Uri.parse('$_baseUrl/api/v1/admin/users/$encodedId');
|
||||||
|
|
||||||
|
final body = <String, dynamic>{};
|
||||||
|
if (email != null) body['email'] = email;
|
||||||
|
if (phone != null) body['phone'] = phone;
|
||||||
|
if (displayName != null) body['displayName'] = displayName;
|
||||||
|
|
||||||
|
final response = await http.patch(
|
||||||
|
url,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Admin-Password': adminPassword,
|
||||||
|
},
|
||||||
|
body: jsonEncode(body),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
throw Exception('Failed to update user: ${response.body}');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
static Future<void> sendLog(String level, String message, {Map<String, dynamic>? data}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
final url = Uri.parse('$_baseUrl/api/v1/client-log');
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
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('/dashboard');
|
||||||
|
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('/dashboard');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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);
|
||||||
|
try {
|
||||||
|
await AuthProxyService.updateUserDetails(
|
||||||
|
adminPassword: _verifiedAdminPassword!,
|
||||||
|
loginId: loginId,
|
||||||
|
displayName: nameController.text.trim(),
|
||||||
|
email: emailController.text.trim(),
|
||||||
|
phone: phoneController.text.trim(),
|
||||||
|
);
|
||||||
|
_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);
|
||||||
|
try {
|
||||||
|
await AuthProxyService.createUser(
|
||||||
|
loginId: _createLoginIdController.text.trim(),
|
||||||
|
adminPassword: _verifiedAdminPassword!,
|
||||||
|
email: _createEmailController.text.trim().isEmpty ? null : _createEmailController.text.trim(),
|
||||||
|
phone: _createPhoneController.text.trim().isEmpty ? null : _createPhoneController.text.trim(),
|
||||||
|
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('/dashboard'),
|
||||||
|
),
|
||||||
|
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: (_, __) => const Divider(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final user = _users[index];
|
||||||
|
final userObj = user['user'] ?? {}; // Descope struct structure might vary
|
||||||
|
// Based on Descope API, user root might have fields directly or inside 'user'
|
||||||
|
// Go SDK SearchAll returns UserResponse struct.
|
||||||
|
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'package:flutter_web_plugins/url_strategy.dart';
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
import 'features/auth/presentation/login_screen.dart';
|
import 'features/auth/presentation/login_screen.dart';
|
||||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/create_user_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'core/services/auth_proxy_service.dart';
|
import 'core/services/auth_proxy_service.dart';
|
||||||
import 'core/services/logger_service.dart';
|
import 'core/services/logger_service.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
@@ -90,7 +90,7 @@ final _router = GoRouter(
|
|||||||
path: '/admin/users',
|
path: '/admin/users',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
_routerLogger.info("Navigating to /admin/users");
|
_routerLogger.info("Navigating to /admin/users");
|
||||||
return const CreateUserScreen();
|
return const UserManagementScreen();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user