diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c57a8608..5ac0d999 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -182,7 +182,7 @@ func main() { // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler(redisService, idpProvider) + authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo) adminHandler := handler.NewAdminHandler() devHandler := handler.NewDevHandler() tenantHandler := handler.NewTenantHandler(db) @@ -414,6 +414,7 @@ func main() { })) api.Post("/audit", auditHandler.CreateLog) api.Get("/audit", auditHandler.ListLogs) + api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline) // Auth Proxy Routes auth := api.Group("/auth") diff --git a/backend/internal/domain/auth_models.go b/backend/internal/domain/auth_models.go index 476cd069..b87fdf9b 100644 --- a/backend/internal/domain/auth_models.go +++ b/backend/internal/domain/auth_models.go @@ -5,6 +5,8 @@ type EnchantedLinkInitRequest struct { URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow) Method string `json:"method,omitempty"` // "email" or "sms" CodeOnly bool `json:"codeOnly,omitempty"` + DryRun bool `json:"dryRun,omitempty"` + DrySend bool `json:"drySend,omitempty"` } type EnchantedLinkInitResponse struct { @@ -83,6 +85,8 @@ type UpdateUserRequest struct { // PasswordResetInitiateRequest is the request body for initiating a password reset. type PasswordResetInitiateRequest struct { LoginID string `json:"loginId"` + DryRun bool `json:"dryRun,omitempty"` + DrySend bool `json:"drySend,omitempty"` } // PasswordResetCompleteRequest is the request body for completing a password reset. diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index e7c74e55..142f54f8 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -61,6 +61,7 @@ const ( minPollInterval = 2 * time.Second loginCodeExpiration = 10 * time.Minute linkResendCooldown = 60 * time.Second + prefixDrySend = "dry_send:" ) type AuthHandler struct { @@ -70,6 +71,7 @@ type AuthHandler struct { RedisService *service.RedisService DescopeClient *client.DescopeClient IdpProvider domain.IdentityProvider + AuditRepo domain.AuditRepository } type signupState struct { @@ -127,7 +129,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du return false, int(interval.Seconds()) } -func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider) *AuthHandler { +func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") @@ -150,6 +152,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident RedisService: redisService, DescopeClient: descopeClient, IdpProvider: idpProvider, + AuditRepo: auditRepo, } } @@ -646,6 +649,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { userfrontURL = req.URI } + drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed() + if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() { + slog.Warn("[Enchanted] DrySend ignored in production", "loginID", loginID) + } if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" { keyLoginID := lookupLoginID if init.LoginID != "" { @@ -662,6 +669,12 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { pendingRef := GenerateSecureToken(3) h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, loginCodeExpiration) + if drySend { + _ = h.RedisService.Set(prefixDrySend+keyLoginID, pendingRef, loginCodeExpiration) + if keyLoginID != lookupLoginID { + _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, loginCodeExpiration) + } + } if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID { _ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration) _ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration) @@ -698,6 +711,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // Store in Redis h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration) h.RedisService.Set(prefixToken+token, fmt.Sprintf(`{"pendingRef":"%s","loginId":"%s"}`, pendingRef, lookupLoginID), defaultExpiration) + if drySend { + _ = h.RedisService.Set(prefixDrySend+lookupLoginID, pendingRef, defaultExpiration) + } // Generate Link slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL) @@ -706,7 +722,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { // Route based on LoginID type if strings.Contains(loginID, "@") { // Send Email - if h.EmailService == nil { + if !drySend && h.EmailService == nil { slog.Error("[Enchanted] Email Service not configured") return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } @@ -725,19 +741,26 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error { `, link, userCode) - slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) - if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { - slog.Error("[Enchanted] Email Failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"}) + if drySend { + slog.Info("[Enchanted][DrySend] Email send skipped", "loginID", loginID, "link", link, "userCode", userCode) + } else { + slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID) + if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { + slog.Error("[Enchanted] Email Failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"}) + } } } else { // Send SMS content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode) - slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) - - if err := h.SmsService.SendSms(loginID, content); err != nil { - slog.Error("[Enchanted] SMS Failed", "error", err) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + if drySend { + slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content) + } else { + slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID) + if err := h.SmsService.SendSms(loginID, content); err != nil { + slog.Error("[Enchanted] SMS Failed", "error", err) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) + } } } @@ -1141,9 +1164,13 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error { ale.RedirectTo = resetLink ale.Operation = "SendPasswordReset" ale.Log(slog.LevelInfo, "Initiating password reset via internal token") + drySend := (req.DrySend || req.DryRun) && service.IsDryRunAllowed() + if (req.DrySend || req.DryRun) && !service.IsDryRunAllowed() { + ale.Log(slog.LevelWarn, "DrySend ignored in production") + } if strings.Contains(loginID, "@") { - if h.EmailService == nil { + if !drySend && h.EmailService == nil { ale.Status = fiber.StatusInternalServerError ale.LatencyMs = time.Since(startTime) ale.DescopeError = "Email service not configured" @@ -1161,20 +1188,29 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {

요청하지 않았다면 이 메일을 무시하세요.

`, resetLink) - if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { - ale.Status = fiber.StatusInternalServerError - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = err.Error() - ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID)) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"}) + if drySend { + ale.Log(slog.LevelInfo, "Email send skipped (dry-send)", slog.String("loginId", loginID), slog.String("link", resetLink)) + } else { + if err := h.EmailService.SendEmail(loginID, subject, body); err != nil { + ale.Status = fiber.StatusInternalServerError + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = err.Error() + ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"}) + } } } else { - if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil { - ale.Status = fiber.StatusInternalServerError - ale.LatencyMs = time.Since(startTime) - ale.DescopeError = err.Error() - ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID)) - return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"}) + resetSms := fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink) + if drySend { + ale.Log(slog.LevelInfo, "SMS send skipped (dry-send)", slog.String("loginId", loginID), slog.String("content", resetSms)) + } else { + if err := h.SmsService.SendSms(loginID, resetSms); err != nil { + ale.Status = fiber.StatusInternalServerError + ale.LatencyMs = time.Since(startTime) + ale.DescopeError = err.Error() + ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"}) + } } } @@ -1548,6 +1584,14 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if !strings.Contains(loginID, "@") { loginID = normalizePhoneForLoginID(loginID) } + drySend := false + if service.IsDryRunAllowed() { + if val, _ := h.RedisService.Get(prefixDrySend + loginID); val != "" { + if pendingRef, _ := h.RedisService.Get(prefixLoginCodePending + loginID); pendingRef != "" && pendingRef == val { + drySend = true + } + } + } if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" { code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code")) if code == "" { @@ -1617,6 +1661,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if smsBody == "" { smsBody = body } + if drySend { + slog.Info("[Kratos Courier][DrySend] SMS send skipped (email relay)", "to", phone, "template", req.TemplateType, "content", smsBody) + return c.JSON(fiber.Map{"status": "ok"}) + } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) @@ -1627,13 +1675,17 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { } if strings.Contains(req.Recipient, "@") { - if h.EmailService == nil { + if !drySend && h.EmailService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"}) } if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" { subject = shortSubject body = shortBody } + if drySend { + slog.Info("[Kratos Courier][DrySend] Email send skipped", "to", req.Recipient, "template", req.TemplateType, "subject", subject) + return c.JSON(fiber.Map{"status": "ok"}) + } if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil { slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send email"}) @@ -1642,7 +1694,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { return c.JSON(fiber.Map{"status": "ok"}) } - if h.SmsService == nil { + if !drySend && h.SmsService == nil { return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"}) } phone := sanitizePhoneForSms(req.Recipient) @@ -1659,6 +1711,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error { if smsBody == "" { smsBody = body } + if drySend { + slog.Info("[Kratos Courier][DrySend] SMS send skipped", "to", phone, "template", req.TemplateType, "content", smsBody) + return c.JSON(fiber.Map{"status": "ok"}) + } if err := h.SmsService.SendSms(phone, smsBody); err != nil { slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"}) @@ -2027,6 +2083,179 @@ func looksLikeJWT(token string) bool { return strings.Count(token, ".") == 2 } +func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error { + if h.AuditRepo == nil { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"}) + } + + limit := c.QueryInt("limit", 20) + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + + profile, err := h.resolveCurrentProfile(c) + if err != nil { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"}) + } + + candidates := buildLoginCandidates(profile) + fetchLimit := limit * 10 + if fetchLimit < limit { + fetchLimit = limit + } + if fetchLimit > 500 { + fetchLimit = 500 + } + + logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, nil) + if err != nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"}) + } + + items := make([]domain.AuditLog, 0, limit) + for _, log := range logs { + if !isAuthEventType(log.EventType) { + continue + } + if !matchesAuthTimelineUser(log, profile, candidates) { + continue + } + if log.UserID == "" { + log.UserID = profile.ID + } + items = append(items, log) + if len(items) >= limit { + break + } + } + + return c.JSON(fiber.Map{ + "items": items, + "limit": limit, + }) +} + +func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) { + token := h.getBearerToken(c) + if token != "" { + if looksLikeJWT(token) && h.DescopeClient != nil { + authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) + if err == nil && authorized { + userResponse, err := h.DescopeClient.Management.User().Load(c.Context(), userToken.ID) + if err != nil { + return nil, err + } + dept, _ := userResponse.CustomAttributes["department"].(string) + affType, _ := userResponse.CustomAttributes["affiliationType"].(string) + compCode, _ := userResponse.CustomAttributes["companyCode"].(string) + return &domain.UserProfileResponse{ + ID: userResponse.UserID, + Email: userResponse.Email, + Name: userResponse.Name, + Phone: h.formatPhoneForDisplay(userResponse.Phone), + Department: dept, + AffiliationType: affType, + CompanyCode: compCode, + }, nil + } + } + + profile, err := h.getKratosProfile(token) + if err != nil { + return nil, err + } + return profile, nil + } + + cookie := c.Get("Cookie") + if cookie == "" { + return nil, fmt.Errorf("missing authorization token") + } + return h.getKratosProfileWithCookie(cookie) +} + +func isAuthEventType(eventType string) bool { + normalized := strings.ToLower(eventType) + return strings.Contains(normalized, " /api/v1/auth/") +} + +func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} { + candidates := make(map[string]struct{}) + if profile == nil { + return candidates + } + for _, raw := range []string{profile.Email, profile.Phone, normalizePhoneForLoginID(profile.Phone)} { + if normalized := normalizeLoginIdentifier(raw); normalized != "" { + candidates[normalized] = struct{}{} + } + } + return candidates +} + +func normalizeLoginIdentifier(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if strings.Contains(trimmed, "@") { + return strings.ToLower(trimmed) + } + return normalizePhoneForLoginID(trimmed) +} + +func matchesAuthTimelineUser(log domain.AuditLog, profile *domain.UserProfileResponse, candidates map[string]struct{}) bool { + if profile == nil { + return false + } + if profile.ID != "" && log.UserID == profile.ID { + return true + } + loginID := extractLoginIDFromAuditDetails(log.Details) + normalized := normalizeLoginIdentifier(loginID) + if normalized == "" { + return false + } + _, ok := candidates[normalized] + return ok +} + +func extractLoginIDFromAuditDetails(details string) string { + if details == "" { + return "" + } + var payload map[string]any + if err := json.Unmarshal([]byte(details), &payload); err != nil { + return "" + } + if raw, ok := payload["login_id"]; ok { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + if raw, ok := payload["loginId"]; ok { + if value, ok := raw.(string); ok && value != "" { + return value + } + } + if raw, ok := payload["request_body"]; ok { + if value, ok := raw.(string); ok && value != "" { + var body map[string]any + if err := json.Unmarshal([]byte(value), &body); err == nil { + if loginID := extractLoginIDFromClaims(body); loginID != "" { + return loginID + } + if target, ok := body["target"].(string); ok && target != "" { + return target + } + } + } + } + return "" +} + func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) { if looksLikeJWT(token) && h.DescopeClient != nil { authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token) diff --git a/userfront/lib/core/services/auth_proxy_service.dart b/userfront/lib/core/services/auth_proxy_service.dart index 2fb3897b..a609298b 100644 --- a/userfront/lib/core/services/auth_proxy_service.dart +++ b/userfront/lib/core/services/auth_proxy_service.dart @@ -12,6 +12,17 @@ class AuthProxyService { } static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + static bool get _isProd { + final env = _envOrDefault('APP_ENV', 'dev').toLowerCase(); + return env == 'prod' || env == 'production'; + } + static bool get isProdEnv => _isProd; + static bool _shouldSendDrySend(bool? drySend) { + if (_isProd) { + return false; + } + return drySend == true; + } static Future> fetchPasswordPolicy() async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy'); @@ -45,6 +56,7 @@ class AuthProxyService { String loginId, { String? method, bool? codeOnly, + bool? drySend, }) async { final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init'); final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr'); @@ -53,6 +65,9 @@ class AuthProxyService { 'loginId': loginId, 'uri': userfrontUrl, }; + if (_shouldSendDrySend(drySend)) { + body['drySend'] = true; + } if (method != null) { body['method'] = method; } @@ -173,12 +188,15 @@ class AuthProxyService { } } - static Future> initiatePasswordReset(String loginId) async { + static Future> initiatePasswordReset(String loginId, {bool? drySend}) async { final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate'); final response = await http.post( url, headers: {'Content-Type': 'application/json'}, - body: jsonEncode({'loginId': loginId}), + body: jsonEncode({ + 'loginId': loginId, + if (_shouldSendDrySend(drySend)) 'drySend': true, + }), ); if (response.statusCode == 200) { diff --git a/userfront/lib/features/auth/presentation/forgot_password_screen.dart b/userfront/lib/features/auth/presentation/forgot_password_screen.dart index ae2b82e7..5e776265 100644 --- a/userfront/lib/features/auth/presentation/forgot_password_screen.dart +++ b/userfront/lib/features/auth/presentation/forgot_password_screen.dart @@ -12,6 +12,13 @@ class ForgotPasswordScreen extends StatefulWidget { class _ForgotPasswordScreenState extends State { final TextEditingController _loginIdController = TextEditingController(); bool _isLoading = false; + bool _drySendEnabled = false; + + @override + void initState() { + super.initState(); + _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; + } Future _handlePasswordReset() async { final input = _loginIdController.text.trim(); @@ -32,7 +39,7 @@ class _ForgotPasswordScreenState extends State { setState(() => _isLoading = true); try { - await AuthProxyService.initiatePasswordReset(loginId); + await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -59,6 +66,14 @@ class _ForgotPasswordScreenState extends State { ); } + bool _parseBoolParam(String? value) { + if (value == null) { + return false; + } + final normalized = value.toLowerCase(); + return normalized == 'true' || normalized == '1' || normalized == 'yes'; + } + @override Widget build(BuildContext context) { return Scaffold( @@ -82,6 +97,29 @@ class _ForgotPasswordScreenState extends State { ), textAlign: TextAlign.center, ), + if (_drySendEnabled) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFC107)), + ), + child: Row( + children: const [ + Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), + SizedBox(width: 8), + Expanded( + child: Text( + "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", + style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), + ), + ), + ], + ), + ), + ], const SizedBox(height: 16), const Text( "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.", diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 18accd55..b03527c0 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; @@ -47,6 +46,8 @@ class _LoginScreenState extends ConsumerState Timer? _linkResendTimer; int _linkExpireSeconds = 0; Timer? _linkExpireTimer; + bool _verificationOnly = false; + bool _drySendEnabled = false; @override void initState() { @@ -54,6 +55,7 @@ class _LoginScreenState extends ConsumerState // 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크") _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabSelection); + _drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; // Check for tokens (Path Parameter or Legacy Query Parameter) WidgetsBinding.instance.addPostFrameCallback((_) { @@ -61,19 +63,25 @@ class _LoginScreenState extends ConsumerState final loginIdParam = uri.queryParameters['loginId']; final codeParam = uri.queryParameters['code']; final pendingRefParam = uri.queryParameters['pendingRef']; - if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l') { + final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l'; + final hasTokenParam = uri.queryParameters.containsKey('t'); + final hasVerificationToken = widget.verificationToken != null || hasTokenParam; + final hasLoginCode = loginIdParam != null && codeParam != null; + _verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath; + + if (hasShortCodePath) { final shortCode = uri.pathSegments[1]; _verifyShortCode(shortCode); } - if (loginIdParam != null && codeParam != null) { + if (hasLoginCode) { _verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam); - } else if (widget.verificationToken != null) { - _verifyToken(widget.verificationToken!); - } else if (uri.queryParameters.containsKey('t')) { - _verifyToken(uri.queryParameters['t']!); + } else if (hasVerificationToken) { + _verifyToken(widget.verificationToken ?? uri.queryParameters['t']!); } - _tryCookieSession(); + if (!_verificationOnly) { + _tryCookieSession(); + } if (uri.queryParameters.containsKey('redirect_url')) { _redirectUrl = uri.queryParameters['redirect_url']; @@ -128,6 +136,14 @@ class _LoginScreenState extends ConsumerState _shortCodeDigitsController.clear(); } + bool _parseBoolParam(String? value) { + if (value == null) { + return false; + } + final normalized = value.toLowerCase(); + return normalized == 'true' || normalized == '1' || normalized == 'yes'; + } + void _startLinkResendTimer(int seconds) { _linkResendSeconds = seconds; _linkResendTimer?.cancel(); @@ -284,40 +300,15 @@ class _LoginScreenState extends ConsumerState return; } - if (res['status'] == 'ok' && res['sessionJwt'] != null) { + if (res['status'] == 'ok') { timer.cancel(); _qrCountdownTimer?.cancel(); - - final token = res['sessionJwt'] as String; - final isJwt = token.split('.').length == 3; - if (isJwt) { - final displayName = _getLoginIdFromJwt(token); - // Create User & Session for Descope SDK - final dummyUser = DescopeUser( - 'unknown', // userId - [], // loginIds - 0, // createdAt - displayName, // name - null, // picture (Uri?) - '', // email - false, // isVerifiedEmail - '', // phone - false, // isVerifiedPhone - {}, // customAttributes - '', // givenName - '', // middleName - '', // familyName - false, // hasPassword - 'enabled', // status - [], // roleNames - [], // ssoAppIds - [], // oauthProviders (List) - ); - final session = DescopeSession.fromJwt(token, token, dummyUser); - Descope.sessionManager.manageSession(session); + final token = res['sessionJwt'] ?? res['token']; + if (token is String && token.isNotEmpty) { + _completeLoginFromToken(token); + } else { + _showError("로그인 토큰을 확인할 수 없습니다."); } - - _onLoginSuccess(token); } } catch (e) { debugPrint("[QR] Polling error: $e"); @@ -339,26 +330,37 @@ class _LoginScreenState extends ConsumerState return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}"; } + void _completeLoginFromToken( + String token, { + String? provider, + bool closeDialog = false, + }) { + final isJwt = token.split('.').length == 3; + if (isJwt) { + final displayName = _getLoginIdFromJwt(token); + final dummyUser = DescopeUser( + 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + ); + final session = DescopeSession.fromJwt(token, token, dummyUser); + Descope.sessionManager.manageSession(session); + } + + if (!mounted) return; + if (closeDialog && Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + _onLoginSuccess(token, provider: provider); + } + Future _verifyToken(String token) async { debugPrint("[Auth] Starting verification for token: $token"); try { // Use Backend to verify the token (Backend-Driven Flow) - final res = await AuthProxyService.verifyMagicLink(token); - final jwt = res['token']; + await AuthProxyService.verifyMagicLink(token); debugPrint("[Auth] Verification successful for token: $token"); - - if (jwt != null && mounted) { - final displayName = _getLoginIdFromJwt(jwt); - // Create User & Session for Descope SDK to log in this tab - final dummyUser = DescopeUser( - 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], - ); - final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); - // Refresh Token을 LocalStorage에 저장 - Descope.sessionManager.manageSession(session); - - // Notify and Go to Dashboard - _onLoginSuccess(jwt); + + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } } catch (e) { debugPrint("[Auth] Verification FAILED for token: $token. Error: $e"); @@ -388,17 +390,15 @@ class _LoginScreenState extends ConsumerState return; } - if (jwt != null && mounted) { - final isJwt = (jwt as String).split('.').length == 3; - if (isJwt) { - final displayName = _getLoginIdFromJwt(jwt); - final dummyUser = DescopeUser( - 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], - ); - final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); - Descope.sessionManager.manageSession(session); + if (_verificationOnly) { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } - _onLoginSuccess(jwt, provider: res['provider'] as String?); + return; + } + + if (jwt is String && jwt.isNotEmpty) { + _completeLoginFromToken(jwt, provider: res['provider'] as String?); } } catch (e) { debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e"); @@ -425,17 +425,15 @@ class _LoginScreenState extends ConsumerState return; } - if (jwt != null && mounted) { - final isJwt = (jwt as String).split('.').length == 3; - if (isJwt) { - final displayName = _getLoginIdFromJwt(jwt); - final dummyUser = DescopeUser( - 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], - ); - final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); - Descope.sessionManager.manageSession(session); + if (_verificationOnly) { + if (mounted) { + _showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다."); } - _onLoginSuccess(jwt, provider: res['provider'] as String?); + return; + } + + if (jwt is String && jwt.isNotEmpty) { + _completeLoginFromToken(jwt, provider: res['provider'] as String?); } } catch (e) { debugPrint("[Auth] Short code verification FAILED. Error: $e"); @@ -543,6 +541,7 @@ class _LoginScreenState extends ConsumerState final initResponse = await AuthProxyService.initEnchantedLink( loginId, codeOnly: codeOnly, + drySend: _drySendEnabled, ); final pendingRef = initResponse['pendingRef']; final mode = (initResponse['mode'] ?? '').toString(); @@ -627,24 +626,22 @@ class _LoginScreenState extends ConsumerState } if (result['status'] == 'ok') { - final jwt = result['sessionJwt']; - if (jwt != null) { + final token = result['sessionJwt'] ?? result['token']; + if (token is String && token.isNotEmpty) { debugPrint("[Auth] Polling SUCCESS. Token received."); - - final displayName = _getLoginIdFromJwt(jwt); - // Descope SDK 세션 강제 주입 - final dummyUser = DescopeUser( - 'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [], + _completeLoginFromToken( + token, + provider: result['provider'] as String?, + closeDialog: true, ); - final session = DescopeSession.fromJwt(jwt, jwt, dummyUser); - Descope.sessionManager.manageSession(session); - - if (mounted) { - Navigator.of(context).pop(); // Close Polling Dialog - _onLoginSuccess(jwt); - } return; } + debugPrint("[Auth] Polling SUCCESS but token missing."); + if (mounted && Navigator.canPop(context)) { + Navigator.of(context).pop(); + } + _showError("로그인 토큰을 확인할 수 없습니다."); + return; } } catch (e) { debugPrint("[Auth] Polling error (attempt $attempts): $e"); @@ -802,13 +799,36 @@ class _LoginScreenState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( - "Baron SSO", + "Baron 통합로그인", style: GoogleFonts.outfit( fontSize: 32, fontWeight: FontWeight.bold, ), textAlign: TextAlign.center, ), + if (_drySendEnabled) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: const Color(0xFFFFC107)), + ), + child: Row( + children: const [ + Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)), + SizedBox(width: 8), + Expanded( + child: Text( + "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.", + style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12), + ), + ), + ], + ), + ), + ], const SizedBox(height: 40), TabBar( diff --git a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart index da0cf4c4..d7dd2886 100644 --- a/userfront/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/userfront/lib/features/dashboard/presentation/dashboard_screen.dart @@ -1,29 +1,227 @@ +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:descope/descope.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../../core/notifiers/auth_notifier.dart'; import '../../../../core/services/auth_token_store.dart'; +import '../../../../core/services/http_client.dart'; import '../../profile/domain/notifiers/profile_notifier.dart'; -class DashboardScreen extends ConsumerWidget { +class AuditLogEntry { + final String eventId; + final DateTime timestamp; + final String userId; + final String eventType; + final String status; + final String ipAddress; + final String details; + + AuditLogEntry({ + required this.eventId, + required this.timestamp, + required this.userId, + required this.eventType, + required this.status, + required this.ipAddress, + required this.details, + }); + + factory AuditLogEntry.fromJson(Map json) { + final timestampRaw = json['timestamp']?.toString() ?? ''; + DateTime parsedTimestamp; + try { + parsedTimestamp = DateTime.parse(timestampRaw).toLocal(); + } catch (_) { + parsedTimestamp = DateTime.now(); + } + + return AuditLogEntry( + eventId: json['event_id'] ?? '', + timestamp: parsedTimestamp, + userId: json['user_id'] ?? '', + eventType: json['event_type'] ?? '', + status: json['status'] ?? '', + ipAddress: json['ip_address'] ?? '', + details: json['details'] ?? '', + ); + } + + Map get detailMap { + if (details.isEmpty) { + return {}; + } + try { + return jsonDecode(details) as Map; + } catch (_) { + return {}; + } + } + + String get path { + final detailPath = detailMap['path']?.toString(); + if (detailPath != null && detailPath.isNotEmpty) { + return detailPath; + } + final parts = eventType.split(' '); + if (parts.length >= 2) { + return parts.sublist(1).join(' '); + } + return '-'; + } +} + +class DashboardScreen extends ConsumerStatefulWidget { const DashboardScreen({super.key}); - Future _logout(BuildContext context) async { - // ignore: use_build_context_synchronously + @override + ConsumerState createState() => _DashboardScreenState(); +} + +class _DashboardScreenState extends ConsumerState { + static const _ink = Color(0xFF1A1F2C); + static const _surface = Colors.white; + static const _border = Color(0xFFE5E7EB); + static const _subtle = Color(0xFFF7F8FA); + + Future>? _auditFuture; + bool _showAllActivities = false; + + @override + void initState() { + super.initState(); + _auditFuture = _fetchAuditLogs(); + } + + Future _logout() async { Descope.sessionManager.clearSession(); AuthTokenStore.clear(); AuthNotifier.instance.notify(); } - void _onScanQR(BuildContext context) { + void _onScanQR() { context.push('/scan'); } + Future _refreshAll() async { + await ref.read(profileProvider.notifier).loadProfile(); + setState(() { + _auditFuture = _fetchAuditLogs(); + }); + if (_auditFuture != null) { + await _auditFuture; + } + } + + static String _envOrDefault(String key, String fallback) { + if (!dotenv.isInitialized) { + return fallback; + } + return dotenv.env[key] ?? fallback; + } + + Future> _fetchAuditLogs() async { + final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr'); + final url = Uri.parse('$baseUrl/api/v1/audit/auth/timeline?limit=20'); + final useCookie = AuthTokenStore.usesCookie(); + final token = AuthTokenStore.getToken(); + + final client = createHttpClient(withCredentials: useCookie); + final headers = { + 'Content-Type': 'application/json', + }; + if (!useCookie && token != null) { + headers['Authorization'] = 'Bearer $token'; + } + + final response = await client.get(url, headers: headers); + client.close(); + + if (response.statusCode != 200) { + throw Exception('Failed to load audit logs'); + } + + final body = jsonDecode(response.body) as Map; + final items = (body['items'] as List?) ?? []; + final logs = items + .whereType>() + .map(AuditLogEntry.fromJson) + .toList(); + + return logs; + } + + DateTime? _getJwtIssuedAt() { + final token = AuthTokenStore.getToken(); + if (token == null || token.isEmpty) { + return null; + } + try { + final parts = token.split('.'); + if (parts.length != 3) { + return null; + } + final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))); + final data = json.decode(payload) as Map; + final iatValue = data['iat'] ?? data['auth_time']; + if (iatValue is num) { + return DateTime.fromMillisecondsSinceEpoch(iatValue.toInt() * 1000).toLocal(); + } + } catch (_) { + return null; + } + return null; + } + + String _formatDateTime(DateTime dateTime) { + final yyyy = dateTime.year.toString().padLeft(4, '0'); + final mm = dateTime.month.toString().padLeft(2, '0'); + final dd = dateTime.day.toString().padLeft(2, '0'); + final hh = dateTime.hour.toString().padLeft(2, '0'); + final min = dateTime.minute.toString().padLeft(2, '0'); + return '$yyyy.$mm.$dd $hh:$min'; + } + + String _authMethodLabel() { + if (AuthTokenStore.usesCookie()) { + return 'Ory 세션'; + } + final provider = AuthTokenStore.getProvider(); + if (provider == null || provider.isEmpty) { + return '세션'; + } + final lower = provider.toLowerCase(); + if (lower.contains('ory')) { + return 'Ory 세션'; + } + if (lower.contains('descope')) { + return 'Descope'; + } + return provider; + } + + String _appLabelForPath(String path) { + if (path.startsWith('/api/v1/auth')) { + return 'Baron 통합로그인'; + } + if (path.startsWith('/api/v1/user')) { + return 'Baron 통합로그인'; + } + if (path.startsWith('/api/v1/dev')) { + return 'Dev Console'; + } + if (path.startsWith('/api/v1/admin')) { + return 'Admin Console'; + } + return 'Baron 통합로그인'; + } + @override - Widget build(BuildContext context, WidgetRef ref) { - final profile = ref.watch(profileProvider).value; + Widget build(BuildContext context) { + final profileState = ref.watch(profileProvider); + final profile = profileState.value; final user = Descope.sessionManager.session?.user; final userName = user?.name ?? user?.email ?? @@ -32,98 +230,503 @@ class DashboardScreen extends ConsumerWidget { profile?.email ?? profile?.phone ?? 'User'; + final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음'; + final sessionIssuedAt = _getJwtIssuedAt(); return Scaffold( - backgroundColor: Colors.grey[50], + backgroundColor: _subtle, appBar: AppBar( - title: Text('Baron SSO', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + title: Text( + 'Baron 통합로그인', + style: GoogleFonts.outfit(fontWeight: FontWeight.bold), + ), elevation: 0, - backgroundColor: Colors.white, + backgroundColor: _surface, foregroundColor: Colors.black, actions: [ + IconButton( + icon: const Icon(Icons.person_outline), + tooltip: '내 정보', + onPressed: () => context.push('/profile'), + ), + IconButton( + icon: const Icon(Icons.qr_code_scanner), + tooltip: 'QR 스캔', + onPressed: _onScanQR, + ), IconButton( icon: const Icon(Icons.logout), - onPressed: () => _logout(context), - tooltip: 'Sign Out', + tooltip: '로그아웃', + onPressed: _logout, ), ], ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), children: [ - const Icon(Icons.check_circle_outline, size: 80, color: Colors.green), - const SizedBox(height: 24), - Text( - '로그인 성공!', - style: GoogleFonts.notoSans( - fontSize: 28, - fontWeight: FontWeight.bold, - color: const Color(0xFF1A1F2C), - ), + ListTile( + leading: const Icon(Icons.person_outline), + title: const Text('내 정보'), + onTap: () { + Navigator.of(context).pop(); + context.push('/profile'); + }, ), - const SizedBox(height: 8), - Text( - '반갑습니다, $userName님', - style: GoogleFonts.notoSans( - fontSize: 16, - color: Colors.grey[600], - ), + ListTile( + leading: const Icon(Icons.qr_code_scanner), + title: const Text('QR 스캔'), + onTap: () { + Navigator.of(context).pop(); + _onScanQR(); + }, ), - const SizedBox(height: 48), - - // QR Camera Button - SizedBox( - width: double.infinity, - height: 56, - child: ElevatedButton( - onPressed: () => _onScanQR(context), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF1A1F2C), - foregroundColor: Colors.white, - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.qr_code_scanner, size: 28), - const SizedBox(width: 12), - Text( - 'QR 스캔하기', - style: GoogleFonts.notoSans(fontSize: 18, fontWeight: FontWeight.w600), - ), - const SizedBox(width: 40), // Icon size(28) + Spacing(12) to balance the centering - ], - ), - ), - ), - const SizedBox(height: 16), - const Text( - 'PC 화면의 QR 코드를 스캔하여 로그인하세요.', - style: TextStyle(color: Colors.grey, fontSize: 13), - ), - const SizedBox(height: 32), - - // My Page Button - OutlinedButton.icon( - onPressed: () => context.push('/profile'), - icon: const Icon(Icons.person), - label: const Text('내 정보 보기'), - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFF1A1F2C), - side: const BorderSide(color: Color(0xFF1A1F2C)), - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - ), + const Divider(), + ListTile( + leading: const Icon(Icons.logout), + title: const Text('로그아웃'), + onTap: () async { + Navigator.of(context).pop(); + await _logout(); + }, ), ], ), ), ), + body: RefreshIndicator( + onRefresh: _refreshAll, + child: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 900; + final isMobile = constraints.maxWidth < 600; + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isMobile) ...[ + _buildHeaderCard(userName, department, sessionIssuedAt), + const SizedBox(height: 28), + ], + _buildSectionTitle('활동상황', '현재 연결된 앱과 최근 인증 상태입니다.'), + const SizedBox(height: 12), + _buildActivityGrid(sessionIssuedAt, isMobile), + const SizedBox(height: 28), + _buildSectionTitle('접속이력', 'Baron 통합로그인 기준의 최근 접근 기록입니다.'), + const SizedBox(height: 12), + _buildAccessHistory(isWide), + ], + ), + ), + ); + }, + ), + ), + ); + } + + Widget _buildHeaderCard(String userName, String department, DateTime? issuedAt) { + final sessionLabel = issuedAt != null ? _formatDateTime(issuedAt) : '알 수 없음'; + final infoColumn = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '안녕하세요, $userName님', + style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: _ink), + ), + const SizedBox(height: 6), + Text( + department, + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _buildInfoChip(Icons.verified_user, '세션 활성'), + _buildInfoChip(Icons.lock_outline, _authMethodLabel()), + _buildInfoChip(Icons.access_time, sessionLabel), + ], + ), + ], + ); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _border), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: infoColumn, + ); + } + + Widget _buildSectionTitle(String title, String subtitle) { + return Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: _ink), + ), + const SizedBox(width: 12), + Text( + subtitle, + style: TextStyle(fontSize: 13, color: Colors.grey[600]), + ), + ], + ); + } + + Widget _buildInfoChip(IconData icon, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: _border), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: _ink), + const SizedBox(width: 6), + Text( + label, + style: const TextStyle(fontSize: 12, color: _ink, fontWeight: FontWeight.w600), + ), + ], + ), + ); + } + + Widget _buildActivityGrid(DateTime? signupAt, bool isMobile) { + final signupLabel = signupAt != null ? _formatDateTime(signupAt) : '확인 필요'; + final activities = [ + _ActivityItem( + appName: 'Baron 통합로그인', + lastAuthAt: signupLabel, + status: '활성', + canLogout: true, + onLogout: _logout, + ), + _ActivityItem( + appName: 'BEPs', + lastAuthAt: '연동 필요', + status: '미연동', + canLogout: false, + ), + _ActivityItem( + appName: 'KNGIL', + lastAuthAt: '연동 필요', + status: '미연동', + canLogout: false, + ), + _ActivityItem( + appName: 'C.E.L', + lastAuthAt: '연동 필요', + status: '미연동', + canLogout: false, + ), + _ActivityItem( + appName: 'EG-BIM', + lastAuthAt: '연동 필요', + status: '미연동', + canLogout: false, + ), + ]; + + if (!isMobile) { + return Wrap( + spacing: 12, + runSpacing: 12, + children: activities.map(_buildActivityCard).toList(), + ); + } + + final visibleCount = _showAllActivities ? activities.length : 4; + final visibleActivities = activities.take(visibleCount).toList(); + final shouldShowToggle = activities.length > 4; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.05, + ), + itemCount: visibleActivities.length, + itemBuilder: (context, index) => _buildActivityCard(visibleActivities[index]), + ), + if (shouldShowToggle) + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: () { + setState(() { + _showAllActivities = !_showAllActivities; + }); + }, + child: Text(_showAllActivities ? '접기' : '더보기'), + ), + ), + ], + ); + } + + Widget _buildActivityCard(_ActivityItem item) { + final statusColor = item.status == '활성' ? Colors.green : Colors.grey; + return Container( + width: 260, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + item.appName, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _ink), + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(999), + ), + child: Text( + item.status, + style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w600), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '가입일시', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + const SizedBox(height: 4), + Text( + item.lastAuthAt, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: _ink), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: item.canLogout ? item.onLogout : null, + style: OutlinedButton.styleFrom( + foregroundColor: _ink, + side: const BorderSide(color: _border), + ), + child: const Text('로그아웃'), + ), + ), + ], + ), + ); + } + + Widget _buildAccessHistory(bool isWide) { + return FutureBuilder>( + future: _auditFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return _buildHistoryContainer( + child: const Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError) { + return _buildHistoryContainer( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('접속이력을 불러오지 못했습니다.'), + const SizedBox(height: 8), + TextButton( + onPressed: () { + setState(() { + _auditFuture = _fetchAuditLogs(); + }); + }, + child: const Text('다시 시도'), + ), + ], + ), + ), + ); + } + + final logs = snapshot.data ?? []; + if (logs.isEmpty) { + return _buildHistoryContainer( + child: Center( + child: Text( + '최근 접속 이력이 없습니다.', + style: TextStyle(color: Colors.grey[600]), + ), + ), + ); + } + + if (isWide) { + return _buildHistoryTable(logs); + } + return _buildHistoryList(logs); + }, + ); + } + + Widget _buildHistoryContainer({required Widget child}) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _border), + ), + child: child, + ); + } + + Widget _buildHistoryTable(List logs) { + return _buildHistoryContainer( + child: LayoutBuilder( + builder: (context, constraints) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.maxWidth), + child: DataTable( + columnSpacing: 16, + horizontalMargin: 12, + columns: const [ + DataColumn(label: Text('접속일자')), + DataColumn(label: Text('어플리케이션')), + DataColumn(label: Text('접속 IP')), + DataColumn(label: Text('인증여부')), + DataColumn(label: Text('인증수단')), + DataColumn(label: Text('현황')), + DataColumn(label: Text('관리')), + ], + rows: logs.take(10).map((log) { + final statusLabel = log.status == 'success' ? '성공' : '실패'; + final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; + final appLabel = _appLabelForPath(log.path); + return DataRow(cells: [ + DataCell(Text(_formatDateTime(log.timestamp))), + DataCell(Text(appLabel)), + DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)), + DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))), + DataCell(Text(_authMethodLabel())), + DataCell(Text(statusLabel == '성공' ? '활성' : '실패')), + const DataCell(Text('원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey))), + ]); + }).toList(), + ), + ), + ); + }, + ), + ); + } + + Widget _buildHistoryList(List logs) { + return _buildHistoryContainer( + child: Column( + children: logs.take(10).map((log) { + final statusLabel = log.status == 'success' ? '성공' : '실패'; + final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent; + final appLabel = _appLabelForPath(log.path); + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: _subtle, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + appLabel, + style: const TextStyle(fontWeight: FontWeight.w600, color: _ink), + ), + ), + Text( + statusLabel, + style: TextStyle(color: statusColor, fontWeight: FontWeight.w600), + ), + ], + ), + const SizedBox(height: 6), + Text('접속일자: ${_formatDateTime(log.timestamp)}'), + Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'), + Text('인증수단: ${_authMethodLabel()}'), + Text('관리: 원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey[600])), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Text( + '원격 로그아웃 준비중', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + ), + ], + ), + ); + }).toList(), + ), ); } } + +class _ActivityItem { + final String appName; + final String lastAuthAt; + final String status; + final bool canLogout; + final VoidCallback? onLogout; + + _ActivityItem({ + required this.appName, + required this.lastAuthAt, + required this.status, + required this.canLogout, + this.onLogout, + }); +} diff --git a/userfront/lib/main.dart b/userfront/lib/main.dart index 7745b09e..06520c15 100644 --- a/userfront/lib/main.dart +++ b/userfront/lib/main.dart @@ -228,7 +228,7 @@ class BaronSSOApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp.router( - title: 'Baron SSO', + title: 'Baron 통합로그인', theme: ThemeData( colorScheme: ColorScheme.fromSeed( seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base diff --git a/userfront/web/index.html b/userfront/web/index.html index b1a8f2de..a1798bc5 100644 --- a/userfront/web/index.html +++ b/userfront/web/index.html @@ -23,13 +23,13 @@ - + - userfront + Baron 통합로그인 diff --git a/userfront/web/manifest.json b/userfront/web/manifest.json index 4b543557..3aeca9f0 100644 --- a/userfront/web/manifest.json +++ b/userfront/web/manifest.json @@ -1,11 +1,11 @@ { - "name": "userfront", - "short_name": "userfront", + "name": "Baron 통합로그인", + "short_name": "Baron 통합로그인", "start_url": ".", "display": "standalone", "background_color": "#0175C2", "theme_color": "#0175C2", - "description": "A new Flutter project.", + "description": "Baron 통합로그인 사용자 포털.", "orientation": "portrait-primary", "prefer_related_applications": false, "icons": [