From 290d5c6c8630acc17c9784e0b167e2bb8bc643ad Mon Sep 17 00:00:00 2001 From: chan Date: Fri, 16 Jan 2026 11:07:55 +0900 Subject: [PATCH] admin page add --- backend/cmd/server/main.go | 6 + backend/internal/handler/admin_handler.go | 144 +++++++++++ backend/internal/handler/auth_handler.go | 58 ++++- .../lib/core/services/auth_proxy_service.dart | 44 ++++ .../presentation/create_user_screen.dart | 232 ++++++++++++++++++ frontend/lib/main.dart | 10 +- 6 files changed, 481 insertions(+), 13 deletions(-) create mode 100644 backend/internal/handler/admin_handler.go create mode 100644 frontend/lib/features/admin/presentation/create_user_screen.dart diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4b1bcea3..5e160deb 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -61,6 +61,7 @@ func main() { // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) authHandler := handler.NewAuthHandler() + adminHandler := handler.NewAdminHandler() // 3. Initialize Fiber app := fiber.New(fiber.Config{ @@ -132,6 +133,11 @@ func main() { auth.Post("/sms", authHandler.SendSms) auth.Post("/verify-sms", authHandler.VerifySms) + // Admin Routes + admin := api.Group("/admin") + admin.Post("/users", adminHandler.CreateUser) + admin.Get("/check", adminHandler.CheckAuth) + // Webhook for Descope Generic SMS Gateway auth.Post("/webhooks/descope-sms", authHandler.HandleDescopeSmsRelay) diff --git a/backend/internal/handler/admin_handler.go b/backend/internal/handler/admin_handler.go new file mode 100644 index 00000000..95871850 --- /dev/null +++ b/backend/internal/handler/admin_handler.go @@ -0,0 +1,144 @@ +package handler + +import ( + "context" + "log" + "os" + "strings" + + "github.com/descope/go-sdk/descope" + "github.com/descope/go-sdk/descope/client" + "github.com/gofiber/fiber/v2" +) + +type AdminHandler struct { + DescopeClient *client.DescopeClient +} + +func NewAdminHandler() *AdminHandler { + projectID := os.Getenv("DESCOPE_PROJECT_ID") + managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") + + var descopeClient *client.DescopeClient + var err error + + if projectID != "" && managementKey != "" { + descopeClient, err = client.NewWithConfig(&client.Config{ + ProjectID: projectID, + ManagementKey: managementKey, + }) + if err != nil { + log.Printf("Warning: Failed to initialize Descope Client for Admin: %v", err) + } + } else { + log.Println("Warning: DESCOPE_PROJECT_ID or DESCOPE_MANAGEMENT_KEY missing. Admin functions will fail.") + } + + return &AdminHandler{ + DescopeClient: descopeClient, + } +} + +func boolPtr(b bool) *bool { + return &b +} + +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) CreateUser(c *fiber.Ctx) error { + // 1. Simple Password Check + adminPass := os.Getenv("ADMIN_PASSWORD") + if adminPass == "" { + adminPass = "admin" // Default fallback + } + + reqPass := c.Get("X-Admin-Password") + if reqPass != adminPass { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid Admin Password"}) + } + + if h.DescopeClient == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) + } + + var req CreateUserRequest + if err := c.BodyParser(&req); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "Invalid request body"}) + } + + if req.LoginID == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "LoginID is required"}) + } + + // Normalize Phone + normalizedPhone := req.Phone + if normalizedPhone != "" { + if strings.HasPrefix(normalizedPhone, "010") { + normalizedPhone = "+82" + normalizedPhone[1:] + } else if strings.HasPrefix(normalizedPhone, "82") { + normalizedPhone = "+" + normalizedPhone + } + } + + userObj := &descope.UserRequest{ + User: descope.User{ + Email: req.Email, + Phone: normalizedPhone, + Name: req.DisplayName, + }, + VerifiedEmail: boolPtr(req.Email != ""), + VerifiedPhone: boolPtr(normalizedPhone != ""), + } + + // Add Roles if provided + if len(req.Roles) > 0 { + userObj.Roles = req.Roles + } + + // Add Tenants if provided + if len(req.Tenants) > 0 { + // Convert map[string][]string to []*descope.AssociatedTenant + userTenants := []*descope.AssociatedTenant{} + for tenantID, roles := range req.Tenants { + userTenants = append(userTenants, &descope.AssociatedTenant{ + TenantID: tenantID, + Roles: roles, + }) + } + userObj.Tenants = userTenants + } + + log.Printf("[Admin] Creating user: %s (Email: %s, Phone: %s)", req.LoginID, req.Email, normalizedPhone) + + res, err := h.DescopeClient.Management.User().Create(context.Background(), req.LoginID, userObj) + if err != nil { + log.Printf("[Admin] Failed to create user: %v", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) + } + + return c.JSON(fiber.Map{ + "message": "User created successfully", + "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"}) +} diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 6e688af2..29bc67b4 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -222,30 +222,64 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Descope Client not configured"}) } - log.Printf("[Verify] Generating embedded link for %s", loginID) - embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), loginID, nil, 0) + // [Fix] Search for existing user by phone to prevent fragmentation + // Normalize Phone Number for Search (E.164) + searchPhone := loginID + if !strings.Contains(searchPhone, "@") { + // If it looks like a KR mobile number (010...), format to +8210... + if strings.HasPrefix(searchPhone, "010") { + searchPhone = "+82" + searchPhone[1:] + } else if strings.HasPrefix(searchPhone, "82") { + searchPhone = "+" + searchPhone + } + } + + log.Printf("[Verify] Searching for user with phone: %s", searchPhone) + searchOptions := &descope.UserSearchOptions{ + Phones: []string{searchPhone}, + Limit: 1, + } + + var targetLoginID string + users, _, errSearch := h.DescopeClient.Management.User().SearchAll(context.Background(), searchOptions) + + if errSearch == nil && len(users) > 0 { + if len(users[0].LoginIDs) > 0 { + targetLoginID = users[0].LoginIDs[0] + log.Printf("[Verify] User found! Existing LoginID: %s", targetLoginID) + } else { + // Should not happen for a valid user, but fallback to UserID or searchPhone + log.Printf("[Verify] User found but no LoginIDs. Using UserID.") + targetLoginID = users[0].UserID + } + } else { + // Not found, or search error. Fallback to using the phone as LoginID. + // Use the normalized phone number to ensure consistency (+82...) + targetLoginID = searchPhone + log.Printf("[Verify] User not found by phone. Will use/create: %s", targetLoginID) + } + + log.Printf("[Verify] Generating embedded link for %s", targetLoginID) + embeddedToken, err := h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) if err != nil { if strings.Contains(err.Error(), "User not found") || strings.Contains(err.Error(), "E062108") { - log.Printf("[Verify] User %s not found. Creating...", loginID) + log.Printf("[Verify] User %s not found. Creating...", targetLoginID) - descopeLoginID := loginID + // Create User with Explicit Phone Attribute userObj := &descope.UserRequest{} - if strings.Contains(loginID, "@") { - userObj.Email = loginID + if strings.Contains(targetLoginID, "@") { + userObj.Email = targetLoginID } else { - if strings.HasPrefix(loginID, "010") { - descopeLoginID = "+82" + loginID[1:] - } - userObj.Phone = descopeLoginID + userObj.Phone = targetLoginID // Must be E.164 } - _, errCreate := h.DescopeClient.Management.User().Create(context.Background(), descopeLoginID, userObj) + _, errCreate := h.DescopeClient.Management.User().Create(context.Background(), targetLoginID, userObj) if errCreate != nil { log.Printf("[Verify] Failed to create user: %v", errCreate) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to create new user"}) } - embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), descopeLoginID, nil, 0) + embeddedToken, err = h.DescopeClient.Management.User().GenerateEmbeddedLink(context.Background(), targetLoginID, nil, 0) if err != nil { log.Printf("[Verify] Failed to generate token after creation: %v", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to generate upstream token"}) diff --git a/frontend/lib/core/services/auth_proxy_service.dart b/frontend/lib/core/services/auth_proxy_service.dart index 1308a69f..27be3335 100644 --- a/frontend/lib/core/services/auth_proxy_service.dart +++ b/frontend/lib/core/services/auth_proxy_service.dart @@ -99,6 +99,50 @@ class AuthProxyService { } } + static Future checkAdminAuth(String adminPassword) async { + final url = Uri.parse('$_baseUrl/api/v1/admin/check'); + try { + final response = await http.get( + url, + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Password': adminPassword, + }, + ); + return response.statusCode == 200; + } catch (_) { + return false; + } + } + + static Future createUser({ + required String loginId, + required String adminPassword, + String? email, + String? phone, + String? displayName, + }) async { + final url = Uri.parse('$_baseUrl/api/v1/admin/users'); + + final response = await http.post( + url, + headers: { + 'Content-Type': 'application/json', + 'X-Admin-Password': adminPassword, + }, + body: jsonEncode({ + 'loginId': loginId, + 'email': email, + 'phone': phone, + 'displayName': displayName, + }), + ); + + if (response.statusCode != 200) { + throw Exception('Failed to create user: ${response.body}'); + } + } + static Future sendLog(String level, String message, {Map? data}) async { final url = Uri.parse('$_baseUrl/api/v1/client-log'); try { diff --git a/frontend/lib/features/admin/presentation/create_user_screen.dart b/frontend/lib/features/admin/presentation/create_user_screen.dart new file mode 100644 index 00000000..35891870 --- /dev/null +++ b/frontend/lib/features/admin/presentation/create_user_screen.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/services/auth_proxy_service.dart'; + +class CreateUserScreen extends StatefulWidget { + const CreateUserScreen({super.key}); + + @override + State createState() => _CreateUserScreenState(); +} + +class _CreateUserScreenState extends State { + final _formKey = GlobalKey(); + final TextEditingController _loginIdController = TextEditingController(); + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _phoneController = TextEditingController(); + final TextEditingController _nameController = TextEditingController(); + + bool _isLoading = false; + bool _isAuthorized = false; + String? _verifiedAdminPassword; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _verifyAccess()); + } + + Future _verifyAccess() async { + final passwordController = TextEditingController(); + + // Show blocking dialog + final String? inputPassword = await showDialog( + context: context, + barrierDismissible: false, // User must enter password or leave + builder: (context) => AlertDialog( + title: const Text("Admin Authentication Required"), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text("Please enter the admin password to access this page."), + 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), // Cancel + child: const Text("Cancel"), + ), + FilledButton( + onPressed: () => Navigator.pop(context, passwordController.text), + child: const Text("Enter"), + ), + ], + ), + ); + + // If cancelled or empty + if (inputPassword == null || inputPassword.isEmpty) { + if (mounted) context.go('/dashboard'); // Kick out + return; + } + + // Verify against Backend + setState(() => _isLoading = true); + final isValid = await AuthProxyService.checkAdminAuth(inputPassword); + setState(() => _isLoading = false); + + if (isValid) { + if (mounted) { + setState(() { + _isAuthorized = true; + _verifiedAdminPassword = inputPassword; + }); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red), + ); + context.go('/dashboard'); // Kick out + } + } + } + + @override + void dispose() { + _loginIdController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _nameController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (_verifiedAdminPassword == null) return; // Should not happen + + setState(() => _isLoading = true); + + try { + await AuthProxyService.createUser( + loginId: _loginIdController.text.trim(), + adminPassword: _verifiedAdminPassword!, + email: _emailController.text.trim().isEmpty ? null : _emailController.text.trim(), + phone: _phoneController.text.trim().isEmpty ? null : _phoneController.text.trim(), + displayName: _nameController.text.trim().isEmpty ? null : _nameController.text.trim(), + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('User created successfully!'), backgroundColor: Colors.green), + ); + _formKey.currentState!.reset(); + _loginIdController.clear(); + _emailController.clear(); + _phoneController.clear(); + _nameController.clear(); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'), backgroundColor: Colors.red), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + // Hide content until authorized + if (!_isAuthorized) { + return const Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('Create User'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/dashboard'), + ), + ), + body: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 600), + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + "Create New User", + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + + TextFormField( + controller: _loginIdController, + 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: _nameController, + decoration: const InputDecoration( + labelText: "Display Name", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.person), + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: "Email", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.email), + ), + ), + const SizedBox(height: 16), + + TextFormField( + controller: _phoneController, + decoration: const InputDecoration( + labelText: "Phone Number", + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.phone), + helperText: "Start with 010 (e.g., 010-1234-5678)", + ), + ), + const SizedBox(height: 32), + + FilledButton( + onPressed: _isLoading ? null : _submit, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text("Create User"), + ), + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 156b3fbd..21073a99 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -8,6 +8,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:flutter_web_plugins/url_strategy.dart'; import 'features/auth/presentation/login_screen.dart'; import 'features/dashboard/presentation/dashboard_screen.dart'; +import 'features/admin/presentation/create_user_screen.dart'; import 'core/services/auth_proxy_service.dart'; import 'core/services/logger_service.dart'; import 'package:logging/logging.dart'; @@ -85,12 +86,19 @@ final _router = GoRouter( return const DashboardScreen(); }, ), + GoRoute( + path: '/admin/users', + builder: (context, state) { + _routerLogger.info("Navigating to /admin/users"); + return const CreateUserScreen(); + }, + ), ], redirect: (context, state) { final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false; final path = state.uri.path; - final isLoggingIn = path == '/' || path.startsWith('/verify/'); + final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/'); _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn");