forked from baron/baron-sso
userfront 이력 session ID기반 작업 완료.
This commit is contained in:
@@ -42,6 +42,7 @@ type PasswordPolicy struct {
|
|||||||
type Token struct {
|
type Token struct {
|
||||||
JWT string
|
JWT string
|
||||||
Expiration time.Time
|
Expiration time.Time
|
||||||
|
SessionID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AuthInfo contains authentication information after a successful login.
|
// AuthInfo contains authentication information after a successful login.
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ type AuditLog struct {
|
|||||||
EventID string `json:"event_id"`
|
EventID string `json:"event_id"`
|
||||||
Timestamp time.Time `json:"timestamp"`
|
Timestamp time.Time `json:"timestamp"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
SessionID string `json:"session_id,omitempty"`
|
||||||
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
EventType string `json:"event_type"` // e.g., "login_success", "login_failed", "otp_sent"
|
||||||
Status string `json:"status"` // e.g., "success", "failure"
|
Status string `json:"status"` // e.g., "success", "failure"
|
||||||
|
AuthMethod string `json:"auth_method,omitempty"`
|
||||||
IPAddress string `json:"ip_address"`
|
IPAddress string `json:"ip_address"`
|
||||||
UserAgent string `json:"user_agent"`
|
UserAgent string `json:"user_agent"`
|
||||||
DeviceID string `json:"device_id,omitempty"`
|
DeviceID string `json:"device_id,omitempty"`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
crand "crypto/rand"
|
crand "crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -41,6 +42,8 @@ const (
|
|||||||
prefixLoginCodeQr = "login_code_qr:"
|
prefixLoginCodeQr = "login_code_qr:"
|
||||||
prefixPollMeta = "poll_meta:"
|
prefixPollMeta = "poll_meta:"
|
||||||
prefixQrRef = "qr_ref:"
|
prefixQrRef = "qr_ref:"
|
||||||
|
prefixQrMeta = "qr_meta:"
|
||||||
|
prefixQrApproverSession = "qr_approver_session:"
|
||||||
prefixQrPending = "qr_pending:"
|
prefixQrPending = "qr_pending:"
|
||||||
prefixSignupEmail = "signup:email:"
|
prefixSignupEmail = "signup:email:"
|
||||||
prefixSignupPhone = "signup:phone:"
|
prefixSignupPhone = "signup:phone:"
|
||||||
@@ -605,6 +608,8 @@ func (h *AuthHandler) VerifySms(c *fiber.Ctx) error {
|
|||||||
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
||||||
}
|
}
|
||||||
|
c.Locals("login_id", loginID)
|
||||||
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"token": authInfo.SessionToken.JWT,
|
"token": authInfo.SessionToken.JWT,
|
||||||
@@ -855,6 +860,8 @@ func (h *AuthHandler) VerifyMagicLink(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
||||||
}
|
}
|
||||||
sessionToken := authInfo.SessionToken.JWT
|
sessionToken := authInfo.SessionToken.JWT
|
||||||
|
c.Locals("login_id", loginID)
|
||||||
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
|
|
||||||
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
slog.Info("[Verify] Success! Updating Redis session", "pendingRef", pendingRef)
|
||||||
sessionData, _ := json.Marshal(map[string]string{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
@@ -912,6 +919,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
|
|||||||
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
||||||
}
|
}
|
||||||
|
c.Locals("login_id", lookupLoginID)
|
||||||
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
|
|
||||||
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
|
h.RedisService.Delete(prefixLoginCode + lookupLoginID)
|
||||||
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + lookupLoginID)
|
||||||
@@ -995,6 +1004,8 @@ func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
|
|||||||
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue session"})
|
||||||
}
|
}
|
||||||
|
c.Locals("login_id", payload.LoginID)
|
||||||
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
|
|
||||||
h.RedisService.Delete(prefixLoginCode + payload.LoginID)
|
h.RedisService.Delete(prefixLoginCode + payload.LoginID)
|
||||||
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
h.RedisService.Delete(prefixLoginCodeShort + shortCode)
|
||||||
@@ -1080,6 +1091,7 @@ func (h *AuthHandler) PasswordLogin(c *fiber.Ctx) error {
|
|||||||
ale.Status = fiber.StatusOK
|
ale.Status = fiber.StatusOK
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.SessionJwt = authInfo.SessionToken.JWT
|
ale.SessionJwt = authInfo.SessionToken.JWT
|
||||||
|
setSessionIDLocal(c, authInfo.SessionToken)
|
||||||
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
ale.Log(slog.LevelInfo, "Login successful", slog.String("provider", h.IdpProvider.Name()), slog.String("subject", authInfo.Subject))
|
||||||
|
|
||||||
resp := fiber.Map{
|
resp := fiber.Map{
|
||||||
@@ -1430,6 +1442,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
|||||||
// Redis에 초기 상태 저장 (5분 만료)
|
// Redis에 초기 상태 저장 (5분 만료)
|
||||||
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), 5*time.Minute)
|
||||||
h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
|
h.RedisService.Set(prefixQrRef+qrRef, pendingRef, 5*time.Minute)
|
||||||
|
h.storeQrMeta(pendingRef, c)
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
return c.JSON(fiber.Map{
|
||||||
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
"qrCode": qrPayload, // 프론트엔드에서 이 텍스트로 QR을 생성하거나, 이미지를 반환
|
||||||
@@ -1514,6 +1527,9 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
|||||||
slog.Warn("[QR] Cookie session invalid", "error", err)
|
slog.Warn("[QR] Cookie session invalid", "error", err)
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||||
}
|
}
|
||||||
|
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil && sessionID != "" {
|
||||||
|
h.storeQrApproverSessionID(pendingRef, sessionID)
|
||||||
|
}
|
||||||
loginID := pickLoginIDFromTraits(traits)
|
loginID := pickLoginIDFromTraits(traits)
|
||||||
if loginID == "" {
|
if loginID == "" {
|
||||||
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
|
||||||
@@ -1529,18 +1545,30 @@ func (h *AuthHandler) ScanQRLogin(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
|
// 2. 모바일 토큰은 승인 검증용으로만 사용하고, 웹 전용 세션을 새로 발급
|
||||||
if sessionToken, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
|
if sessionToken, loginID, approvedSessionID, err := h.tryIssueDescopeQrSession(c, req.Token); err != nil {
|
||||||
slog.Error("[QR] Issue web session failed", "error", err)
|
slog.Error("[QR] Issue web session failed", "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to issue web session"})
|
||||||
} else if sessionToken != "" {
|
} else if sessionToken != nil && sessionToken.JWT != "" {
|
||||||
|
h.storeQrApproverSessionID(pendingRef, approvedSessionID)
|
||||||
|
h.writeQrAuditLog(loginID, pendingRef, sessionToken, approvedSessionID)
|
||||||
sessionData, _ := json.Marshal(map[string]string{
|
sessionData, _ := json.Marshal(map[string]string{
|
||||||
"status": statusSuccess,
|
"status": statusSuccess,
|
||||||
"jwt": sessionToken,
|
"jwt": sessionToken.JWT,
|
||||||
})
|
})
|
||||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute)
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), 5*time.Minute)
|
||||||
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
return c.JSON(fiber.Map{"message": "QR Login Approved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
approvedSessionID := ""
|
||||||
|
if req.Token != "" {
|
||||||
|
if sessionID, err := h.getKratosSessionID(req.Token); err == nil {
|
||||||
|
approvedSessionID = sessionID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if approvedSessionID != "" {
|
||||||
|
h.storeQrApproverSessionID(pendingRef, approvedSessionID)
|
||||||
|
}
|
||||||
|
|
||||||
loginID, err := h.resolveKratosLoginID(req.Token)
|
loginID, err := h.resolveKratosLoginID(req.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("[QR] Invalid token", "error", err)
|
slog.Warn("[QR] Invalid token", "error", err)
|
||||||
@@ -1613,6 +1641,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
"jwt": authInfo.SessionToken.JWT,
|
"jwt": authInfo.SessionToken.JWT,
|
||||||
})
|
})
|
||||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
||||||
|
h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
|
||||||
h.RedisService.Delete(prefixLoginCodeQrPending + loginID)
|
h.RedisService.Delete(prefixLoginCodeQrPending + loginID)
|
||||||
h.RedisService.Delete(prefixLoginCode + loginID)
|
h.RedisService.Delete(prefixLoginCode + loginID)
|
||||||
h.RedisService.Delete(prefixLoginCodeQr + pendingRef)
|
h.RedisService.Delete(prefixLoginCodeQr + pendingRef)
|
||||||
@@ -1640,6 +1669,7 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
"jwt": authInfo.SessionToken.JWT,
|
"jwt": authInfo.SessionToken.JWT,
|
||||||
})
|
})
|
||||||
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
h.RedisService.Set(prefixSession+pendingRef, string(sessionData), loginCodeExpiration)
|
||||||
|
h.writeQrAuditLog(loginID, pendingRef, authInfo.SessionToken, "")
|
||||||
h.RedisService.Delete(prefixQrPending + loginID)
|
h.RedisService.Delete(prefixQrPending + loginID)
|
||||||
h.RedisService.Delete(prefixLoginCode + loginID)
|
h.RedisService.Delete(prefixLoginCode + loginID)
|
||||||
h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
|
h.RedisService.Delete(prefixLoginCodeSmsTarget + loginID)
|
||||||
@@ -2083,6 +2113,176 @@ func looksLikeJWT(token string) bool {
|
|||||||
return strings.Count(token, ".") == 2
|
return strings.Count(token, ".") == 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setSessionIDLocal(c *fiber.Ctx, token *domain.Token) {
|
||||||
|
if c == nil || token == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sessionID := extractSessionIDFromToken(token); sessionID != "" {
|
||||||
|
c.Locals("session_id", sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSessionIDFromToken(token *domain.Token) string {
|
||||||
|
if token == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if token.SessionID != "" {
|
||||||
|
return token.SessionID
|
||||||
|
}
|
||||||
|
if token.JWT != "" {
|
||||||
|
return extractSessionIDFromJWT(token.JWT)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractSessionIDFromJWT(token string) string {
|
||||||
|
if !looksLikeJWT(token) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.Split(token, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
payload, err = base64.URLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var claims map[string]any
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"sid", "session_id", "sessionId", "jti"} {
|
||||||
|
if raw, ok := claims[key]; ok {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type qrMeta struct {
|
||||||
|
IPAddress string `json:"ip_address"`
|
||||||
|
UserAgent string `json:"user_agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) storeQrMeta(pendingRef string, c *fiber.Ctx) {
|
||||||
|
if h.RedisService == nil || pendingRef == "" || c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta := qrMeta{
|
||||||
|
IPAddress: extractClientIPFromHeaders(c),
|
||||||
|
UserAgent: c.Get("User-Agent"),
|
||||||
|
}
|
||||||
|
raw, err := json.Marshal(meta)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.RedisService.Set(prefixQrMeta+pendingRef, string(raw), 5*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) loadQrMeta(pendingRef string) (qrMeta, bool) {
|
||||||
|
if h.RedisService == nil || pendingRef == "" {
|
||||||
|
return qrMeta{}, false
|
||||||
|
}
|
||||||
|
val, err := h.RedisService.Get(prefixQrMeta + pendingRef)
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return qrMeta{}, false
|
||||||
|
}
|
||||||
|
var meta qrMeta
|
||||||
|
if err := json.Unmarshal([]byte(val), &meta); err != nil {
|
||||||
|
return qrMeta{}, false
|
||||||
|
}
|
||||||
|
return meta, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) storeQrApproverSessionID(pendingRef, sessionID string) {
|
||||||
|
if h.RedisService == nil || pendingRef == "" || sessionID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.RedisService.Set(prefixQrApproverSession+pendingRef, sessionID, loginCodeExpiration)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) loadQrApproverSessionID(pendingRef string) string {
|
||||||
|
if h.RedisService == nil || pendingRef == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
val, err := h.RedisService.Get(prefixQrApproverSession + pendingRef)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) writeQrAuditLog(loginID, pendingRef string, sessionToken *domain.Token, approvedSessionID string) {
|
||||||
|
if h.AuditRepo == nil || pendingRef == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, ok := h.loadQrMeta(pendingRef)
|
||||||
|
if !ok {
|
||||||
|
meta = qrMeta{
|
||||||
|
IPAddress: "",
|
||||||
|
UserAgent: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if approvedSessionID == "" {
|
||||||
|
approvedSessionID = h.loadQrApproverSessionID(pendingRef)
|
||||||
|
}
|
||||||
|
sessionID := extractSessionIDFromToken(sessionToken)
|
||||||
|
details := map[string]any{
|
||||||
|
"path": "/api/v1/auth/qr/approve",
|
||||||
|
"login_id": loginID,
|
||||||
|
"pending_ref": pendingRef,
|
||||||
|
}
|
||||||
|
if sessionID != "" {
|
||||||
|
details["session_id"] = sessionID
|
||||||
|
}
|
||||||
|
if approvedSessionID != "" {
|
||||||
|
details["approved_session_id"] = approvedSessionID
|
||||||
|
}
|
||||||
|
detailsJSON, _ := json.Marshal(details)
|
||||||
|
|
||||||
|
log := &domain.AuditLog{
|
||||||
|
EventID: GenerateSecureToken(16),
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
UserID: "",
|
||||||
|
SessionID: sessionID,
|
||||||
|
EventType: "POST /api/v1/auth/qr/approve",
|
||||||
|
Status: "success",
|
||||||
|
IPAddress: meta.IPAddress,
|
||||||
|
UserAgent: meta.UserAgent,
|
||||||
|
Details: string(detailsJSON),
|
||||||
|
AuthMethod: "QR",
|
||||||
|
}
|
||||||
|
_ = h.AuditRepo.Create(log)
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractClientIPFromHeaders(c *fiber.Ctx) string {
|
||||||
|
if c == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
parts := strings.Split(forwarded, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
return c.IP()
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
||||||
if h.AuditRepo == nil {
|
if h.AuditRepo == nil {
|
||||||
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
|
||||||
@@ -2126,6 +2326,13 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
if log.UserID == "" {
|
if log.UserID == "" {
|
||||||
log.UserID = profile.ID
|
log.UserID = profile.ID
|
||||||
}
|
}
|
||||||
|
log.AuthMethod = deriveAuthMethod(log)
|
||||||
|
if log.AuthMethod == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if log.SessionID == "" {
|
||||||
|
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
|
||||||
|
}
|
||||||
items = append(items, log)
|
items = append(items, log)
|
||||||
if len(items) >= limit {
|
if len(items) >= limit {
|
||||||
break
|
break
|
||||||
@@ -2182,6 +2389,141 @@ func isAuthEventType(eventType string) bool {
|
|||||||
return strings.Contains(normalized, " /api/v1/auth/")
|
return strings.Contains(normalized, " /api/v1/auth/")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractAuditPath(log domain.AuditLog) string {
|
||||||
|
if log.Details != "" {
|
||||||
|
if payload, err := parseAuditDetails(log.Details); err == nil {
|
||||||
|
if path, ok := payload["path"].(string); ok && path != "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(log.EventType, " ", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return strings.TrimSpace(parts[1])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAuditDetails(details string) (map[string]any, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if details == "" {
|
||||||
|
return nil, fmt.Errorf("empty details")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractRequestBody(details map[string]any) map[string]any {
|
||||||
|
if details == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, ok := details["request_body"].(string)
|
||||||
|
if !ok || raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var body map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
func loginIDKind(loginID string) string {
|
||||||
|
normalized := strings.TrimSpace(loginID)
|
||||||
|
if normalized == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(normalized, "@") {
|
||||||
|
return "email"
|
||||||
|
}
|
||||||
|
return "phone"
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveAuthMethod(log domain.AuditLog) string {
|
||||||
|
path := strings.ToLower(extractAuditPath(log))
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
loginID := extractLoginIDFromAuditDetails(log.Details)
|
||||||
|
kind := loginIDKind(loginID)
|
||||||
|
details, _ := parseAuditDetails(log.Details)
|
||||||
|
requestBody := extractRequestBody(details)
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.Contains(path, "/api/v1/auth/password/login"):
|
||||||
|
if kind == "email" {
|
||||||
|
return "비밀번호(Email)"
|
||||||
|
}
|
||||||
|
if kind == "phone" {
|
||||||
|
return "비밀번호(전화번호)"
|
||||||
|
}
|
||||||
|
return "비밀번호"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/enchanted-link/init"):
|
||||||
|
if requestBody != nil {
|
||||||
|
if raw, ok := requestBody["codeOnly"]; ok {
|
||||||
|
if value, ok := raw.(bool); ok && value {
|
||||||
|
if kind == "phone" {
|
||||||
|
return "코드(SMS)"
|
||||||
|
}
|
||||||
|
if kind == "email" {
|
||||||
|
return "코드(Email)"
|
||||||
|
}
|
||||||
|
return "코드"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if requestBody != nil {
|
||||||
|
if raw, ok := requestBody["method"].(string); ok {
|
||||||
|
method := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if method == "sms" {
|
||||||
|
return "링크(SMS)"
|
||||||
|
}
|
||||||
|
if method == "email" {
|
||||||
|
return "링크(Email)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "phone" {
|
||||||
|
return "링크(SMS)"
|
||||||
|
}
|
||||||
|
if kind == "email" {
|
||||||
|
return "링크(Email)"
|
||||||
|
}
|
||||||
|
return "링크"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
|
||||||
|
if kind == "phone" {
|
||||||
|
return "링크(SMS)"
|
||||||
|
}
|
||||||
|
if kind == "email" {
|
||||||
|
return "링크(Email)"
|
||||||
|
}
|
||||||
|
return "링크"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
|
||||||
|
if kind == "phone" {
|
||||||
|
return "코드(SMS)"
|
||||||
|
}
|
||||||
|
if kind == "email" {
|
||||||
|
return "코드(Email)"
|
||||||
|
}
|
||||||
|
return "코드"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/login/code/verify-short"):
|
||||||
|
return "코드(간편)"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/verify-sms"):
|
||||||
|
return "코드(SMS)"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/qr/approve"):
|
||||||
|
return "QR"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/qr/init"):
|
||||||
|
return "QR"
|
||||||
|
case strings.Contains(path, "/api/v1/auth/qr/poll"):
|
||||||
|
return "QR"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
|
func buildLoginCandidates(profile *domain.UserProfileResponse) map[string]struct{} {
|
||||||
candidates := make(map[string]struct{})
|
candidates := make(map[string]struct{})
|
||||||
if profile == nil {
|
if profile == nil {
|
||||||
@@ -2256,6 +2598,25 @@ func extractLoginIDFromAuditDetails(details string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractSessionIDFromAuditDetails(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["session_id"]; ok {
|
||||||
|
switch value := raw.(type) {
|
||||||
|
case string:
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
||||||
if looksLikeJWT(token) && h.DescopeClient != nil {
|
if looksLikeJWT(token) && h.DescopeClient != nil {
|
||||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||||
@@ -2267,26 +2628,26 @@ func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, err
|
|||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (string, error) {
|
func (h *AuthHandler) tryIssueDescopeQrSession(c *fiber.Ctx, token string) (*domain.Token, string, string, error) {
|
||||||
if !looksLikeJWT(token) || h.DescopeClient == nil {
|
if !looksLikeJWT(token) || h.DescopeClient == nil {
|
||||||
return "", nil
|
return nil, "", "", nil
|
||||||
}
|
}
|
||||||
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
authorized, userToken, err := h.DescopeClient.Auth.ValidateSessionWithToken(c.Context(), token)
|
||||||
if err != nil || !authorized {
|
if err != nil || !authorized {
|
||||||
return "", nil
|
return nil, "", "", nil
|
||||||
}
|
}
|
||||||
loginID, err := h.resolveDescopeLoginID(c.Context(), userToken)
|
loginID, err := h.resolveDescopeLoginID(c.Context(), userToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
authInfo, err := h.IdpProvider.IssueSession(loginID)
|
authInfo, err := h.IdpProvider.IssueSession(loginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
if authInfo == nil || authInfo.SessionToken == nil || authInfo.SessionToken.JWT == "" {
|
||||||
return "", fmt.Errorf("descope issue session returned empty token")
|
return nil, "", "", fmt.Errorf("descope issue session returned empty token")
|
||||||
}
|
}
|
||||||
return authInfo.SessionToken.JWT, nil
|
return authInfo.SessionToken, loginID, userToken.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
|
func (h *AuthHandler) resolveKratosLoginID(token string) (string, error) {
|
||||||
@@ -2544,6 +2905,36 @@ func (h *AuthHandler) getKratosIdentity(sessionToken string) (string, map[string
|
|||||||
return result.Identity.ID, result.Identity.Traits, nil
|
return result.Identity.ID, result.Identity.Traits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) getKratosSessionID(sessionToken string) (string, error) {
|
||||||
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
|
if kratosURL == "" {
|
||||||
|
kratosURL = "http://kratos:4433"
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Session-Token", sessionToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
|
func (h *AuthHandler) issueKratosSession(ctx context.Context, identityID string) (string, error) {
|
||||||
if identityID == "" {
|
if identityID == "" {
|
||||||
return "", fmt.Errorf("kratos identity id is empty")
|
return "", fmt.Errorf("kratos identity id is empty")
|
||||||
@@ -2621,6 +3012,36 @@ func (h *AuthHandler) getKratosIdentityWithCookie(cookie string) (string, map[st
|
|||||||
return result.Identity.ID, result.Identity.Traits, nil
|
return result.Identity.ID, result.Identity.Traits, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) getKratosSessionIDWithCookie(cookie string) (string, error) {
|
||||||
|
kratosURL := strings.TrimRight(os.Getenv("KRATOS_PUBLIC_URL"), "/")
|
||||||
|
if kratosURL == "" {
|
||||||
|
kratosURL = "http://kratos:4433"
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, kratosURL+"/sessions/whoami", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Cookie", cookie)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return "", fmt.Errorf("kratos whoami failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
|
func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]interface{}) error {
|
||||||
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
kratosAdminURL := strings.TrimRight(os.Getenv("KRATOS_ADMIN_URL"), "/")
|
||||||
if kratosAdminURL == "" {
|
if kratosAdminURL == "" {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -117,6 +118,8 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
userID, _ := c.Locals("user_id").(string)
|
userID, _ := c.Locals("user_id").(string)
|
||||||
loginID, _ := c.Locals("login_id").(string)
|
loginID, _ := c.Locals("login_id").(string)
|
||||||
tenantID, _ := c.Locals("tenant_id").(string)
|
tenantID, _ := c.Locals("tenant_id").(string)
|
||||||
|
sessionID, _ := c.Locals("session_id").(string)
|
||||||
|
clientIP := extractClientIP(c)
|
||||||
|
|
||||||
// 6. Capture & Mask Body
|
// 6. Capture & Mask Body
|
||||||
var maskedBody string
|
var maskedBody string
|
||||||
@@ -141,6 +144,9 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
"tenant_id": tenantID,
|
"tenant_id": tenantID,
|
||||||
"request_body": maskedBody,
|
"request_body": maskedBody,
|
||||||
}
|
}
|
||||||
|
if sessionID != "" {
|
||||||
|
details["session_id"] = sessionID
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
details["error"] = err.Error()
|
details["error"] = err.Error()
|
||||||
}
|
}
|
||||||
@@ -152,9 +158,10 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
EventID: reqID,
|
EventID: reqID,
|
||||||
Timestamp: start,
|
Timestamp: start,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
|
SessionID: sessionID,
|
||||||
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
EventType: fmt.Sprintf("%s %s", c.Method(), c.Path()),
|
||||||
Status: statusText,
|
Status: statusText,
|
||||||
IPAddress: c.IP(),
|
IPAddress: clientIP,
|
||||||
UserAgent: c.Get("User-Agent"),
|
UserAgent: c.Get("User-Agent"),
|
||||||
Details: string(detailsJSON),
|
Details: string(detailsJSON),
|
||||||
}
|
}
|
||||||
@@ -190,3 +197,18 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractClientIP(c *fiber.Ctx) string {
|
||||||
|
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
||||||
|
parts := strings.Split(forwarded, ",")
|
||||||
|
if len(parts) > 0 {
|
||||||
|
if ip := strings.TrimSpace(parts[0]); ip != "" {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" {
|
||||||
|
return realIP
|
||||||
|
}
|
||||||
|
return c.IP()
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ func (d *DescopeProvider) SignIn(loginID, password string) (*domain.AuthInfo, er
|
|||||||
SessionToken: &domain.Token{
|
SessionToken: &domain.Token{
|
||||||
JWT: authInfo.SessionToken.JWT,
|
JWT: authInfo.SessionToken.JWT,
|
||||||
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
||||||
|
SessionID: authInfo.SessionToken.ID,
|
||||||
},
|
},
|
||||||
Subject: authInfo.User.UserID,
|
Subject: authInfo.User.UserID,
|
||||||
}
|
}
|
||||||
@@ -201,6 +202,7 @@ func (d *DescopeProvider) IssueSession(loginID string) (*domain.AuthInfo, error)
|
|||||||
SessionToken: &domain.Token{
|
SessionToken: &domain.Token{
|
||||||
JWT: authInfo.SessionToken.JWT,
|
JWT: authInfo.SessionToken.JWT,
|
||||||
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
||||||
|
SessionID: authInfo.SessionToken.ID,
|
||||||
},
|
},
|
||||||
Subject: authInfo.User.UserID,
|
Subject: authInfo.User.UserID,
|
||||||
}
|
}
|
||||||
@@ -276,6 +278,7 @@ func (d *DescopeProvider) VerifyPasswordResetToken(token string) (*domain.AuthIn
|
|||||||
SessionToken: &domain.Token{
|
SessionToken: &domain.Token{
|
||||||
JWT: authInfo.SessionToken.JWT,
|
JWT: authInfo.SessionToken.JWT,
|
||||||
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
Expiration: time.Unix(authInfo.SessionToken.Expiration, 0),
|
||||||
|
SessionID: authInfo.SessionToken.ID,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if authInfo.RefreshToken != nil {
|
if authInfo.RefreshToken != nil {
|
||||||
|
|||||||
18
backend/internal/service/dry_run_service.go
Normal file
18
backend/internal/service/dry_run_service.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsProductionEnv() bool {
|
||||||
|
env := strings.ToLower(os.Getenv("APP_ENV"))
|
||||||
|
if env == "" {
|
||||||
|
env = strings.ToLower(os.Getenv("GO_ENV"))
|
||||||
|
}
|
||||||
|
return env == "prod" || env == "production"
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDryRunAllowed() bool {
|
||||||
|
return !IsProductionEnv()
|
||||||
|
}
|
||||||
@@ -182,6 +182,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
|
|||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
||||||
Session struct {
|
Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
@@ -204,6 +205,7 @@ func (o *OryProvider) SignIn(loginID, password string) (*domain.AuthInfo, error)
|
|||||||
SessionToken: &domain.Token{
|
SessionToken: &domain.Token{
|
||||||
JWT: result.SessionToken,
|
JWT: result.SessionToken,
|
||||||
Expiration: result.SessionTokenExpiresAt,
|
Expiration: result.SessionTokenExpiresAt,
|
||||||
|
SessionID: result.Session.ID,
|
||||||
},
|
},
|
||||||
Subject: result.Session.Identity.ID,
|
Subject: result.Session.Identity.ID,
|
||||||
}, nil
|
}, nil
|
||||||
@@ -626,6 +628,7 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut
|
|||||||
SessionToken string `json:"session_token"`
|
SessionToken string `json:"session_token"`
|
||||||
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
SessionTokenExpiresAt time.Time `json:"session_token_expires_at"`
|
||||||
Session struct {
|
Session struct {
|
||||||
|
ID string `json:"id"`
|
||||||
Identity struct {
|
Identity struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
} `json:"identity"`
|
} `json:"identity"`
|
||||||
@@ -648,6 +651,7 @@ func (o *OryProvider) VerifyLoginCode(loginID, flowID, code string) (*domain.Aut
|
|||||||
SessionToken: &domain.Token{
|
SessionToken: &domain.Token{
|
||||||
JWT: result.SessionToken,
|
JWT: result.SessionToken,
|
||||||
Expiration: result.SessionTokenExpiresAt,
|
Expiration: result.SessionTokenExpiresAt,
|
||||||
|
SessionID: result.Session.ID,
|
||||||
},
|
},
|
||||||
Subject: result.Session.Identity.ID,
|
Subject: result.Session.Identity.ID,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
1
userfront/lib/core/ui/layout_breakpoints.dart
Normal file
1
userfront/lib/core/ui/layout_breakpoints.dart
Normal file
@@ -0,0 +1 @@
|
|||||||
|
const double sideMenuBreakpoint = 1400;
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:descope/descope.dart';
|
import 'package:descope/descope.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
@@ -8,6 +9,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
import '../../../../core/services/auth_token_store.dart';
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
import '../../../../core/services/http_client.dart';
|
import '../../../../core/services/http_client.dart';
|
||||||
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
import '../../profile/domain/notifiers/profile_notifier.dart';
|
import '../../profile/domain/notifiers/profile_notifier.dart';
|
||||||
|
|
||||||
class AuditLogEntry {
|
class AuditLogEntry {
|
||||||
@@ -16,7 +18,10 @@ class AuditLogEntry {
|
|||||||
final String userId;
|
final String userId;
|
||||||
final String eventType;
|
final String eventType;
|
||||||
final String status;
|
final String status;
|
||||||
|
final String authMethod;
|
||||||
final String ipAddress;
|
final String ipAddress;
|
||||||
|
final String userAgent;
|
||||||
|
final String sessionId;
|
||||||
final String details;
|
final String details;
|
||||||
|
|
||||||
AuditLogEntry({
|
AuditLogEntry({
|
||||||
@@ -25,7 +30,10 @@ class AuditLogEntry {
|
|||||||
required this.userId,
|
required this.userId,
|
||||||
required this.eventType,
|
required this.eventType,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
required this.authMethod,
|
||||||
required this.ipAddress,
|
required this.ipAddress,
|
||||||
|
required this.userAgent,
|
||||||
|
required this.sessionId,
|
||||||
required this.details,
|
required this.details,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,7 +52,10 @@ class AuditLogEntry {
|
|||||||
userId: json['user_id'] ?? '',
|
userId: json['user_id'] ?? '',
|
||||||
eventType: json['event_type'] ?? '',
|
eventType: json['event_type'] ?? '',
|
||||||
status: json['status'] ?? '',
|
status: json['status'] ?? '',
|
||||||
|
authMethod: json['auth_method'] ?? '',
|
||||||
ipAddress: json['ip_address'] ?? '',
|
ipAddress: json['ip_address'] ?? '',
|
||||||
|
userAgent: json['user_agent'] ?? '',
|
||||||
|
sessionId: json['session_id'] ?? '',
|
||||||
details: json['details'] ?? '',
|
details: json['details'] ?? '',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -105,6 +116,58 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
context.push('/scan');
|
context.push('/scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSideMenu(BuildContext context, {required bool closeOnTap}) {
|
||||||
|
return SafeArea(
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.home_outlined),
|
||||||
|
title: const Text('대시보드'),
|
||||||
|
selected: true,
|
||||||
|
onTap: () {
|
||||||
|
if (closeOnTap) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
context.go('/');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: const Text('내 정보'),
|
||||||
|
onTap: () {
|
||||||
|
if (closeOnTap) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
context.push('/profile');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
|
title: const Text('QR 스캔'),
|
||||||
|
onTap: () {
|
||||||
|
if (closeOnTap) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
_onScanQR();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: const Text('로그아웃'),
|
||||||
|
onTap: () async {
|
||||||
|
if (closeOnTap) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
await _logout();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshAll() async {
|
Future<void> _refreshAll() async {
|
||||||
await ref.read(profileProvider.notifier).loadProfile();
|
await ref.read(profileProvider.notifier).loadProfile();
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -202,6 +265,95 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return provider;
|
return provider;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _deviceLabelFromUserAgent(String userAgent) {
|
||||||
|
if (userAgent.isEmpty) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
final ua = userAgent.toLowerCase();
|
||||||
|
if (ua.contains('iphone') || ua.contains('ipad') || ua.contains('ipod')) {
|
||||||
|
return 'Mobile(iOS)';
|
||||||
|
}
|
||||||
|
if (ua.contains('android')) {
|
||||||
|
return 'Mobile(Android)';
|
||||||
|
}
|
||||||
|
if (ua.contains('windows')) {
|
||||||
|
return 'Desktop(Windows)';
|
||||||
|
}
|
||||||
|
if (ua.contains('mac os x') || ua.contains('macintosh')) {
|
||||||
|
return 'Desktop(macOS)';
|
||||||
|
}
|
||||||
|
if (ua.contains('linux')) {
|
||||||
|
return 'Desktop(Linux)';
|
||||||
|
}
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthMethodCell(AuditLogEntry log, String authMethod) {
|
||||||
|
if (authMethod != 'QR') {
|
||||||
|
return Text(authMethod);
|
||||||
|
}
|
||||||
|
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
|
||||||
|
final tooltip = approvedSessionId.isEmpty
|
||||||
|
? '승인한 세션 ID 없음'
|
||||||
|
: '승인한 세션 ID: $approvedSessionId\n클릭하면 복사됩니다.';
|
||||||
|
return InkWell(
|
||||||
|
onTap: approvedSessionId.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('세션 ID가 복사되었습니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Text(
|
||||||
|
'QR',
|
||||||
|
style: TextStyle(
|
||||||
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
||||||
|
decoration:
|
||||||
|
approvedSessionId.isEmpty ? null : TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAuthMethodLine(AuditLogEntry log, String authMethod) {
|
||||||
|
if (authMethod != 'QR') {
|
||||||
|
return Text('인증수단: $authMethod');
|
||||||
|
}
|
||||||
|
final approvedSessionId = log.detailMap['approved_session_id']?.toString() ?? '';
|
||||||
|
return InkWell(
|
||||||
|
onTap: approvedSessionId.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: approvedSessionId));
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('세션 ID가 복사되었습니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Tooltip(
|
||||||
|
message: approvedSessionId.isEmpty
|
||||||
|
? '승인한 세션 ID 없음'
|
||||||
|
: '승인한 세션 ID: $approvedSessionId\n탭하면 복사됩니다.',
|
||||||
|
child: Text(
|
||||||
|
'인증수단: QR',
|
||||||
|
style: TextStyle(
|
||||||
|
color: approvedSessionId.isEmpty ? _ink : Colors.blueAccent,
|
||||||
|
decoration: approvedSessionId.isEmpty
|
||||||
|
? null
|
||||||
|
: TextDecoration.underline,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _appLabelForPath(String path) {
|
String _appLabelForPath(String path) {
|
||||||
if (path.startsWith('/api/v1/auth')) {
|
if (path.startsWith('/api/v1/auth')) {
|
||||||
return 'Baron 통합로그인';
|
return 'Baron 통합로그인';
|
||||||
@@ -220,6 +372,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
|
||||||
final profileState = ref.watch(profileProvider);
|
final profileState = ref.watch(profileProvider);
|
||||||
final profile = profileState.value;
|
final profile = profileState.value;
|
||||||
final user = Descope.sessionManager.session?.user;
|
final user = Descope.sessionManager.session?.user;
|
||||||
@@ -261,70 +414,48 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
drawer: Drawer(
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context, closeOnTap: true)),
|
||||||
child: SafeArea(
|
body: Row(
|
||||||
child: ListView(
|
children: [
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
if (isWide)
|
||||||
children: [
|
SizedBox(
|
||||||
ListTile(
|
width: 240,
|
||||||
leading: const Icon(Icons.person_outline),
|
child: _buildSideMenu(context, closeOnTap: false),
|
||||||
title: const Text('내 정보'),
|
),
|
||||||
onTap: () {
|
Expanded(
|
||||||
Navigator.of(context).pop();
|
child: RefreshIndicator(
|
||||||
context.push('/profile');
|
onRefresh: _refreshAll,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final timelineWide = 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(timelineWide),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
ListTile(
|
),
|
||||||
leading: const Icon(Icons.qr_code_scanner),
|
|
||||||
title: const Text('QR 스캔'),
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
_onScanQR();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -631,26 +762,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
columnSpacing: 16,
|
columnSpacing: 16,
|
||||||
horizontalMargin: 12,
|
horizontalMargin: 12,
|
||||||
columns: const [
|
columns: const [
|
||||||
|
DataColumn(label: Text('Session ID')),
|
||||||
DataColumn(label: Text('접속일자')),
|
DataColumn(label: Text('접속일자')),
|
||||||
DataColumn(label: Text('어플리케이션')),
|
DataColumn(label: Text('애플리케이션')),
|
||||||
DataColumn(label: Text('접속 IP')),
|
DataColumn(label: Text('IP')),
|
||||||
DataColumn(label: Text('인증여부')),
|
DataColumn(label: Text('접속환경')),
|
||||||
DataColumn(label: Text('인증수단')),
|
DataColumn(label: Text('인증수단')),
|
||||||
|
DataColumn(label: Text('인증결과')),
|
||||||
DataColumn(label: Text('현황')),
|
DataColumn(label: Text('현황')),
|
||||||
DataColumn(label: Text('관리')),
|
|
||||||
],
|
],
|
||||||
rows: logs.take(10).map((log) {
|
rows: logs.take(10).map((log) {
|
||||||
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
||||||
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
||||||
final appLabel = _appLabelForPath(log.path);
|
final appLabel = _appLabelForPath(log.path);
|
||||||
|
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
|
||||||
|
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
||||||
return DataRow(cells: [
|
return DataRow(cells: [
|
||||||
|
DataCell(Text(log.sessionId.isEmpty ? '-' : log.sessionId)),
|
||||||
DataCell(Text(_formatDateTime(log.timestamp))),
|
DataCell(Text(_formatDateTime(log.timestamp))),
|
||||||
DataCell(Text(appLabel)),
|
DataCell(Text(appLabel)),
|
||||||
DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
|
DataCell(Text(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
|
||||||
|
DataCell(Text(deviceLabel)),
|
||||||
|
DataCell(_buildAuthMethodCell(log, authMethod)),
|
||||||
DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
|
DataCell(Text(statusLabel, style: TextStyle(color: statusColor, fontWeight: FontWeight.w600))),
|
||||||
DataCell(Text(_authMethodLabel())),
|
const DataCell(Text('(준비중)', style: TextStyle(color: Colors.grey))),
|
||||||
DataCell(Text(statusLabel == '성공' ? '활성' : '실패')),
|
|
||||||
const DataCell(Text('원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey))),
|
|
||||||
]);
|
]);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
),
|
),
|
||||||
@@ -668,6 +803,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
final statusLabel = log.status == 'success' ? '성공' : '실패';
|
||||||
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
|
||||||
final appLabel = _appLabelForPath(log.path);
|
final appLabel = _appLabelForPath(log.path);
|
||||||
|
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
|
||||||
|
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -694,18 +831,13 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
|
Text('Session ID: ${log.sessionId.isEmpty ? '-' : log.sessionId}'),
|
||||||
Text('접속일자: ${_formatDateTime(log.timestamp)}'),
|
Text('접속일자: ${_formatDateTime(log.timestamp)}'),
|
||||||
Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
|
Text('접속 IP: ${log.ipAddress.isEmpty ? '-' : log.ipAddress}'),
|
||||||
Text('인증수단: ${_authMethodLabel()}'),
|
Text('접속환경: $deviceLabel'),
|
||||||
Text('관리: 원격 로그아웃(준비중)', style: TextStyle(color: Colors.grey[600])),
|
_buildAuthMethodLine(log, authMethod),
|
||||||
const SizedBox(height: 8),
|
Text('인증결과: $statusLabel'),
|
||||||
Align(
|
Text('현황: (준비중)', style: TextStyle(color: Colors.grey[600])),
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: Text(
|
|
||||||
'원격 로그아웃 준비중',
|
|
||||||
style: TextStyle(color: Colors.grey[600], fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,251 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
|
||||||
|
|
||||||
class EditProfilePage extends ConsumerStatefulWidget {
|
|
||||||
const EditProfilePage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
late TextEditingController _nameController;
|
|
||||||
late TextEditingController _phoneController;
|
|
||||||
late TextEditingController _codeController;
|
|
||||||
late TextEditingController _departmentController;
|
|
||||||
|
|
||||||
String? _initialPhone;
|
|
||||||
bool _isPhoneChanged = false;
|
|
||||||
bool _isPhoneVerified = false;
|
|
||||||
bool _isCodeSent = false;
|
|
||||||
bool _isVerifying = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
final profile = ref.read(profileProvider).value;
|
|
||||||
_initialPhone = profile?.phone ?? '';
|
|
||||||
_nameController = TextEditingController(text: profile?.name ?? '');
|
|
||||||
_phoneController = TextEditingController(text: _initialPhone);
|
|
||||||
_codeController = TextEditingController();
|
|
||||||
_departmentController = TextEditingController(text: profile?.department ?? '');
|
|
||||||
|
|
||||||
_phoneController.addListener(() {
|
|
||||||
setState(() {
|
|
||||||
_isPhoneChanged = _phoneController.text != _initialPhone;
|
|
||||||
if (_isPhoneChanged) {
|
|
||||||
_isPhoneVerified = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_nameController.dispose();
|
|
||||||
_phoneController.dispose();
|
|
||||||
_codeController.dispose();
|
|
||||||
_departmentController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sendCode() async {
|
|
||||||
final phone = _phoneController.text;
|
|
||||||
if (phone.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isVerifying = true);
|
|
||||||
try {
|
|
||||||
await ref.read(profileRepositoryProvider).sendUpdateCode(phone);
|
|
||||||
setState(() {
|
|
||||||
_isCodeSent = true;
|
|
||||||
_isVerifying = false;
|
|
||||||
});
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('인증번호가 전송되었습니다.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setState(() => _isVerifying = false);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('전송 실패: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _verifyCode() async {
|
|
||||||
final phone = _phoneController.text;
|
|
||||||
final code = _codeController.text;
|
|
||||||
if (code.isEmpty) return;
|
|
||||||
|
|
||||||
setState(() => _isVerifying = true);
|
|
||||||
try {
|
|
||||||
await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code);
|
|
||||||
setState(() {
|
|
||||||
_isPhoneVerified = true;
|
|
||||||
_isVerifying = false;
|
|
||||||
});
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('인증되었습니다.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setState(() => _isVerifying = false);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('인증 실패: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _save() async {
|
|
||||||
if (!_formKey.currentState!.validate()) return;
|
|
||||||
if (_isPhoneChanged && !_isPhoneVerified) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ref.read(profileProvider.notifier).updateProfile(
|
|
||||||
name: _nameController.text,
|
|
||||||
phone: _phoneController.text,
|
|
||||||
department: _departmentController.text,
|
|
||||||
);
|
|
||||||
if (mounted) {
|
|
||||||
context.pop();
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('정보가 수정되었습니다.')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('수정 실패: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final profileState = ref.watch(profileProvider);
|
|
||||||
final isUpdating = profileState.isLoading;
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('내 정보 수정'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: (isUpdating || (_isPhoneChanged && !_isPhoneVerified)) ? null : _save,
|
|
||||||
child: const Text('저장'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
body: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: ListView(
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _nameController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '이름',
|
|
||||||
prefixIcon: Icon(Icons.person_outline),
|
|
||||||
),
|
|
||||||
validator: (value) => (value == null || value.isEmpty) ? '이름을 입력해주세요.' : null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
|
|
||||||
// Phone Number Field
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _phoneController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: '휴대폰 번호',
|
|
||||||
hintText: '01012345678',
|
|
||||||
prefixIcon: const Icon(Icons.phone_android),
|
|
||||||
suffixIcon: _isPhoneVerified
|
|
||||||
? const Icon(Icons.check_circle, color: Colors.green)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
enabled: !_isPhoneVerified,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
if (_isPhoneChanged && !_isPhoneVerified)
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isVerifying ? null : _sendCode,
|
|
||||||
child: Text(_isCodeSent ? '재전송' : '인증요청'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
// OTP Code Field
|
|
||||||
if (_isCodeSent && !_isPhoneVerified) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _codeController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '인증번호',
|
|
||||||
hintText: '6자리 입력',
|
|
||||||
prefixIcon: Icon(Icons.security),
|
|
||||||
),
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _isVerifying ? null : _verifyCode,
|
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue[700], foregroundColor: Colors.white),
|
|
||||||
child: const Text('확인'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
|
|
||||||
if (_isPhoneChanged && !_isPhoneVerified)
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 8.0, left: 4.0),
|
|
||||||
child: Text(
|
|
||||||
'휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
|
|
||||||
style: TextStyle(color: Colors.orange, fontSize: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
TextFormField(
|
|
||||||
controller: _departmentController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: '소속 (부서)',
|
|
||||||
prefixIcon: Icon(Icons.business),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
if (isUpdating || _isVerifying)
|
|
||||||
const Center(child: CircularProgressIndicator()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +1,691 @@
|
|||||||
|
import 'package:descope/descope.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import '../../../../core/notifiers/auth_notifier.dart';
|
||||||
|
import '../../../../core/services/auth_token_store.dart';
|
||||||
|
import '../../../../core/ui/layout_breakpoints.dart';
|
||||||
|
import '../../data/models/user_profile_model.dart';
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
import '../../domain/notifiers/profile_notifier.dart';
|
||||||
import '../widgets/profile_info_row.dart';
|
|
||||||
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerStatefulWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||||||
// profileState is AsyncValue<UserProfile?>
|
}
|
||||||
final profileState = ref.watch(profileProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||||
appBar: AppBar(
|
static const _ink = Color(0xFF1A1F2C);
|
||||||
title: const Text('내 정보'),
|
static const _surface = Colors.white;
|
||||||
actions: [
|
static const _border = Color(0xFFE5E7EB);
|
||||||
IconButton(
|
static const _subtle = Color(0xFFF7F8FA);
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
onPressed: () => context.push('/profile/edit'),
|
UserProfile? _cachedProfile;
|
||||||
|
String? _editingField;
|
||||||
|
TextEditingController? _nameController;
|
||||||
|
TextEditingController? _phoneController;
|
||||||
|
TextEditingController? _departmentController;
|
||||||
|
TextEditingController? _codeController;
|
||||||
|
|
||||||
|
String _initialPhone = '';
|
||||||
|
bool _isPhoneChanged = false;
|
||||||
|
bool _isPhoneVerified = false;
|
||||||
|
bool _isCodeSent = false;
|
||||||
|
bool _isVerifying = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameController?.dispose();
|
||||||
|
_phoneController?.dispose();
|
||||||
|
_departmentController?.dispose();
|
||||||
|
_codeController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
|
Descope.sessionManager.clearSession();
|
||||||
|
AuthTokenStore.clear();
|
||||||
|
AuthNotifier.instance.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _ensureControllers(UserProfile profile) {
|
||||||
|
_nameController ??= TextEditingController(text: profile.name);
|
||||||
|
_departmentController ??= TextEditingController(text: profile.department);
|
||||||
|
_codeController ??= TextEditingController();
|
||||||
|
|
||||||
|
if (_phoneController == null) {
|
||||||
|
_phoneController = TextEditingController(text: profile.phone);
|
||||||
|
_initialPhone = profile.phone;
|
||||||
|
_phoneController!.addListener(_onPhoneChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_editingField != 'name' && _nameController!.text != profile.name) {
|
||||||
|
_nameController!.text = profile.name;
|
||||||
|
}
|
||||||
|
if (_editingField != 'department' && _departmentController!.text != profile.department) {
|
||||||
|
_departmentController!.text = profile.department;
|
||||||
|
}
|
||||||
|
if (_editingField != 'phone' && _phoneController!.text != profile.phone) {
|
||||||
|
_phoneController!.text = profile.phone;
|
||||||
|
_initialPhone = profile.phone;
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onPhoneChanged() {
|
||||||
|
if (_phoneController == null) return;
|
||||||
|
final changed = _phoneController!.text != _initialPhone;
|
||||||
|
if (changed != _isPhoneChanged) {
|
||||||
|
setState(() {
|
||||||
|
_isPhoneChanged = changed;
|
||||||
|
if (_isPhoneChanged) {
|
||||||
|
_isPhoneVerified = false;
|
||||||
|
_isCodeSent = false;
|
||||||
|
_codeController?.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetPhoneState() {
|
||||||
|
_isPhoneChanged = false;
|
||||||
|
_isPhoneVerified = false;
|
||||||
|
_isCodeSent = false;
|
||||||
|
_isVerifying = false;
|
||||||
|
_codeController?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startEditing(String field, UserProfile profile) {
|
||||||
|
setState(() {
|
||||||
|
_editingField = field;
|
||||||
|
if (field == 'name') {
|
||||||
|
_nameController?.text = profile.name;
|
||||||
|
} else if (field == 'department') {
|
||||||
|
_departmentController?.text = profile.department;
|
||||||
|
} else if (field == 'phone') {
|
||||||
|
_phoneController?.text = profile.phone;
|
||||||
|
_initialPhone = profile.phone;
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _cancelEditing(UserProfile profile) {
|
||||||
|
setState(() {
|
||||||
|
if (_editingField == 'name') {
|
||||||
|
_nameController?.text = profile.name;
|
||||||
|
} else if (_editingField == 'department') {
|
||||||
|
_departmentController?.text = profile.department;
|
||||||
|
} else if (_editingField == 'phone') {
|
||||||
|
_phoneController?.text = profile.phone;
|
||||||
|
_initialPhone = profile.phone;
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
_editingField = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _sendCode() async {
|
||||||
|
final phone = _phoneController?.text ?? '';
|
||||||
|
if (phone.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isVerifying = true);
|
||||||
|
try {
|
||||||
|
await ref.read(profileRepositoryProvider).sendUpdateCode(phone);
|
||||||
|
setState(() {
|
||||||
|
_isCodeSent = true;
|
||||||
|
_isVerifying = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('인증번호가 전송되었습니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isVerifying = false);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('전송 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _verifyCode() async {
|
||||||
|
final phone = _phoneController?.text ?? '';
|
||||||
|
final code = _codeController?.text ?? '';
|
||||||
|
if (code.isEmpty) return;
|
||||||
|
|
||||||
|
setState(() => _isVerifying = true);
|
||||||
|
try {
|
||||||
|
await ref.read(profileRepositoryProvider).verifyUpdateCode(phone, code);
|
||||||
|
setState(() {
|
||||||
|
_isPhoneVerified = true;
|
||||||
|
_isVerifying = false;
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('인증되었습니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setState(() => _isVerifying = false);
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('인증 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _saveField(UserProfile profile) async {
|
||||||
|
if (_editingField == null) return;
|
||||||
|
|
||||||
|
final nextName = _editingField == 'name'
|
||||||
|
? _nameController!.text.trim()
|
||||||
|
: profile.name;
|
||||||
|
final nextPhone = _editingField == 'phone'
|
||||||
|
? _phoneController!.text.trim()
|
||||||
|
: profile.phone;
|
||||||
|
final nextDepartment = _editingField == 'department'
|
||||||
|
? _departmentController!.text.trim()
|
||||||
|
: profile.department;
|
||||||
|
|
||||||
|
if (_editingField == 'name' && nextName.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('이름을 입력해주세요.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_editingField == 'department' && nextDepartment.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('소속을 입력해주세요.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_editingField == 'phone') {
|
||||||
|
if (nextPhone.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('휴대폰 번호를 입력해주세요.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_isPhoneChanged && !_isPhoneVerified) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('휴대폰 번호 인증이 필요합니다.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref.read(profileProvider.notifier).updateProfile(
|
||||||
|
name: nextName,
|
||||||
|
phone: nextPhone,
|
||||||
|
department: nextDepartment,
|
||||||
|
);
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (_editingField == 'phone') {
|
||||||
|
_initialPhone = nextPhone;
|
||||||
|
_resetPhoneState();
|
||||||
|
}
|
||||||
|
_editingField = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('정보가 수정되었습니다.')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('수정 실패: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSideMenu(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.home_outlined),
|
||||||
|
title: const Text('대시보드'),
|
||||||
|
onTap: () => context.go('/'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.person_outline),
|
||||||
|
title: const Text('내 정보'),
|
||||||
|
selected: true,
|
||||||
|
onTap: () => context.go('/profile'),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
|
title: const Text('QR 스캔'),
|
||||||
|
onTap: () => context.go('/scan'),
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.logout),
|
||||||
|
title: const Text('로그아웃'),
|
||||||
|
onTap: _logout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: profileState.when(
|
);
|
||||||
data: (profile) {
|
}
|
||||||
if (profile == null) {
|
|
||||||
return const Center(child: Text('정보를 불러올 수 없습니다.'));
|
Widget _buildHeaderCard(UserProfile profile) {
|
||||||
}
|
final name = profile.name.isEmpty ? '이름 없음' : profile.name;
|
||||||
return RefreshIndicator(
|
final email = profile.email.isEmpty ? '이메일 없음' : profile.email;
|
||||||
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
|
final department = profile.department.isEmpty ? '소속 정보 없음' : profile.department;
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
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: Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(
|
||||||
|
radius: 32,
|
||||||
|
child: Icon(Icons.person, size: 32),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Center(
|
Text(
|
||||||
child: CircleAvatar(
|
'안녕하세요, $name님',
|
||||||
radius: 40,
|
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: _ink),
|
||||||
child: Icon(Icons.person, size: 40),
|
),
|
||||||
),
|
const SizedBox(height: 6),
|
||||||
|
Text(email, style: TextStyle(color: Colors.grey[600], fontSize: 14)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_buildInfoChip(Icons.badge_outlined, '프로필 관리'),
|
||||||
|
_buildInfoChip(Icons.apartment, department),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
ProfileInfoRow(label: '이름', value: profile.name),
|
|
||||||
ProfileInfoRow(label: '이메일', value: profile.email),
|
|
||||||
ProfileInfoRow(label: '전화번호', value: profile.phone),
|
|
||||||
const Divider(height: 32),
|
|
||||||
ProfileInfoRow(label: '소속', value: profile.department),
|
|
||||||
ProfileInfoRow(label: '구분', value: profile.affiliationType),
|
|
||||||
if (profile.companyCode.isNotEmpty)
|
|
||||||
ProfileInfoRow(label: '회사코드', value: profile.companyCode),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (err, stack) => Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('오류 발생: $err'),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
|
||||||
child: const Text('재시도'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
Widget _buildCard(Widget child) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: _border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.03),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildReadOnlyTile(String label, String value) {
|
||||||
|
final displayValue = value.isEmpty ? '-' : value;
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(label),
|
||||||
|
subtitle: Text(displayValue),
|
||||||
|
trailing: Text(
|
||||||
|
'읽기 전용',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 12),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEditableTile({
|
||||||
|
required String field,
|
||||||
|
required String label,
|
||||||
|
required String value,
|
||||||
|
required UserProfile profile,
|
||||||
|
required bool isUpdating,
|
||||||
|
required TextEditingController controller,
|
||||||
|
}) {
|
||||||
|
final isEditing = _editingField == field;
|
||||||
|
final displayValue = value.isEmpty ? '-' : value;
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: Text(label),
|
||||||
|
subtitle: Text(displayValue),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: isUpdating ? null : () => _startEditing(field, profile),
|
||||||
|
child: const Text('수정'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(label, style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: label,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isUpdating ? null : () => _saveField(profile),
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPhoneEditor(UserProfile profile, bool isUpdating) {
|
||||||
|
final isEditing = _editingField == 'phone';
|
||||||
|
final displayValue = profile.phone.isEmpty ? '-' : profile.phone;
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('전화번호'),
|
||||||
|
subtitle: Text(displayValue),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: isUpdating ? null : () => _startEditing('phone', profile),
|
||||||
|
child: const Text('수정'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text('전화번호', style: TextStyle(fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _phoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
hintText: '01012345678',
|
||||||
|
suffixIcon: _isPhoneVerified
|
||||||
|
? const Icon(Icons.check_circle, color: Colors.green)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
enabled: !_isPhoneVerified,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (_isPhoneChanged && !_isPhoneVerified)
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isVerifying ? null : _sendCode,
|
||||||
|
child: Text(_isCodeSent ? '재전송' : '인증요청'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_isCodeSent && !_isPhoneVerified) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _codeController,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
hintText: '인증번호 6자리',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _isVerifying ? null : _verifyCode,
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (_isPhoneChanged && !_isPhoneVerified)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(top: 8.0),
|
||||||
|
child: Text(
|
||||||
|
'휴대폰 번호를 변경하려면 SMS 인증이 필요합니다.',
|
||||||
|
style: TextStyle(color: Colors.orange, fontSize: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: isUpdating ? null : () => _cancelEditing(profile),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isUpdating ? null : () => _saveField(profile),
|
||||||
|
child: const Text('확인'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContent(UserProfile profile, bool isUpdating) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(24.0),
|
||||||
|
children: [
|
||||||
|
_buildHeaderCard(profile),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildSectionTitle('기본 정보', '계정 기본 정보를 관리합니다.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildCard(
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
_buildEditableTile(
|
||||||
|
field: 'name',
|
||||||
|
label: '이름',
|
||||||
|
value: profile.name,
|
||||||
|
profile: profile,
|
||||||
|
isUpdating: isUpdating,
|
||||||
|
controller: _nameController!,
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildReadOnlyTile('이메일', profile.email),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildPhoneEditor(profile, isUpdating),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
_buildSectionTitle('조직 정보', '소속 및 구분 정보입니다.'),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildCard(
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
_buildEditableTile(
|
||||||
|
field: 'department',
|
||||||
|
label: '소속',
|
||||||
|
value: profile.department,
|
||||||
|
profile: profile,
|
||||||
|
isUpdating: isUpdating,
|
||||||
|
controller: _departmentController!,
|
||||||
|
),
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildReadOnlyTile('구분', profile.affiliationType),
|
||||||
|
if (profile.companyCode.isNotEmpty) ...[
|
||||||
|
const Divider(height: 24),
|
||||||
|
_buildReadOnlyTile('회사코드', profile.companyCode),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isUpdating || _isVerifying) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final profileState = ref.watch(profileProvider);
|
||||||
|
if (profileState.value != null) {
|
||||||
|
_cachedProfile = profileState.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
final profile = profileState.value ?? _cachedProfile;
|
||||||
|
if (profile == null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('내 정보')),
|
||||||
|
body: profileState.isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text('정보를 불러올 수 없습니다.'),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => ref.read(profileProvider.notifier).loadProfile(),
|
||||||
|
child: const Text('재시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ensureControllers(profile);
|
||||||
|
|
||||||
|
final isWide = MediaQuery.of(context).size.width >= sideMenuBreakpoint;
|
||||||
|
final isUpdating = profileState.isLoading;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: _subtle,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(
|
||||||
|
'Baron 통합로그인',
|
||||||
|
style: GoogleFonts.outfit(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
backgroundColor: _surface,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.home_outlined),
|
||||||
|
tooltip: '대시보드',
|
||||||
|
onPressed: () => context.go('/'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.qr_code_scanner),
|
||||||
|
tooltip: 'QR 스캔',
|
||||||
|
onPressed: () => context.push('/scan'),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.logout),
|
||||||
|
tooltip: '로그아웃',
|
||||||
|
onPressed: _logout,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
|
body: Row(
|
||||||
|
children: [
|
||||||
|
if (isWide)
|
||||||
|
SizedBox(
|
||||||
|
width: 240,
|
||||||
|
child: _buildSideMenu(context),
|
||||||
|
),
|
||||||
|
Expanded(child: _buildContent(profile, isUpdating)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import 'features/auth/presentation/reset_password_screen.dart';
|
|||||||
import 'features/dashboard/presentation/dashboard_screen.dart';
|
import 'features/dashboard/presentation/dashboard_screen.dart';
|
||||||
import 'features/admin/presentation/user_management_screen.dart';
|
import 'features/admin/presentation/user_management_screen.dart';
|
||||||
import 'features/profile/presentation/pages/profile_page.dart';
|
import 'features/profile/presentation/pages/profile_page.dart';
|
||||||
import 'features/profile/presentation/pages/edit_profile_page.dart';
|
|
||||||
import 'core/services/auth_proxy_service.dart';
|
import 'core/services/auth_proxy_service.dart';
|
||||||
import 'core/services/auth_token_store.dart';
|
import 'core/services/auth_token_store.dart';
|
||||||
import 'core/services/logger_service.dart';
|
import 'core/services/logger_service.dart';
|
||||||
@@ -88,12 +87,6 @@ final _router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: 'edit',
|
|
||||||
builder: (context, state) => const EditProfilePage(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/signin',
|
path: '/signin',
|
||||||
@@ -236,8 +229,33 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
textTheme: GoogleFonts.interTextTheme(),
|
textTheme: GoogleFonts.interTextTheme(),
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.android: NoTransitionsBuilder(),
|
||||||
|
TargetPlatform.iOS: NoTransitionsBuilder(),
|
||||||
|
TargetPlatform.linux: NoTransitionsBuilder(),
|
||||||
|
TargetPlatform.macOS: NoTransitionsBuilder(),
|
||||||
|
TargetPlatform.windows: NoTransitionsBuilder(),
|
||||||
|
TargetPlatform.fuchsia: NoTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
routerConfig: _router,
|
routerConfig: _router,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class NoTransitionsBuilder extends PageTransitionsBuilder {
|
||||||
|
const NoTransitionsBuilder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget buildTransitions<T>(
|
||||||
|
PageRoute<T> route,
|
||||||
|
BuildContext context,
|
||||||
|
Animation<double> animation,
|
||||||
|
Animation<double> secondaryAnimation,
|
||||||
|
Widget child,
|
||||||
|
) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user