forked from baron/baron-sso
로그인 이력확인
This commit is contained in:
@@ -182,7 +182,7 @@ func main() {
|
|||||||
|
|
||||||
// 2. Initialize Handlers
|
// 2. Initialize Handlers
|
||||||
auditHandler := handler.NewAuditHandler(auditRepo)
|
auditHandler := handler.NewAuditHandler(auditRepo)
|
||||||
authHandler := handler.NewAuthHandler(redisService, idpProvider)
|
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo)
|
||||||
adminHandler := handler.NewAdminHandler()
|
adminHandler := handler.NewAdminHandler()
|
||||||
devHandler := handler.NewDevHandler()
|
devHandler := handler.NewDevHandler()
|
||||||
tenantHandler := handler.NewTenantHandler(db)
|
tenantHandler := handler.NewTenantHandler(db)
|
||||||
@@ -414,6 +414,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
api.Post("/audit", auditHandler.CreateLog)
|
api.Post("/audit", auditHandler.CreateLog)
|
||||||
api.Get("/audit", auditHandler.ListLogs)
|
api.Get("/audit", auditHandler.ListLogs)
|
||||||
|
api.Get("/audit/auth/timeline", authHandler.GetAuthTimeline)
|
||||||
|
|
||||||
// Auth Proxy Routes
|
// Auth Proxy Routes
|
||||||
auth := api.Group("/auth")
|
auth := api.Group("/auth")
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ type EnchantedLinkInitRequest struct {
|
|||||||
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
URI string `json:"uri,omitempty"` // Redirect URI (optional for polling flow)
|
||||||
Method string `json:"method,omitempty"` // "email" or "sms"
|
Method string `json:"method,omitempty"` // "email" or "sms"
|
||||||
CodeOnly bool `json:"codeOnly,omitempty"`
|
CodeOnly bool `json:"codeOnly,omitempty"`
|
||||||
|
DryRun bool `json:"dryRun,omitempty"`
|
||||||
|
DrySend bool `json:"drySend,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type EnchantedLinkInitResponse struct {
|
type EnchantedLinkInitResponse struct {
|
||||||
@@ -83,6 +85,8 @@ type UpdateUserRequest struct {
|
|||||||
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
// PasswordResetInitiateRequest is the request body for initiating a password reset.
|
||||||
type PasswordResetInitiateRequest struct {
|
type PasswordResetInitiateRequest struct {
|
||||||
LoginID string `json:"loginId"`
|
LoginID string `json:"loginId"`
|
||||||
|
DryRun bool `json:"dryRun,omitempty"`
|
||||||
|
DrySend bool `json:"drySend,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
// PasswordResetCompleteRequest is the request body for completing a password reset.
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const (
|
|||||||
minPollInterval = 2 * time.Second
|
minPollInterval = 2 * time.Second
|
||||||
loginCodeExpiration = 10 * time.Minute
|
loginCodeExpiration = 10 * time.Minute
|
||||||
linkResendCooldown = 60 * time.Second
|
linkResendCooldown = 60 * time.Second
|
||||||
|
prefixDrySend = "dry_send:"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
@@ -70,6 +71,7 @@ type AuthHandler struct {
|
|||||||
RedisService *service.RedisService
|
RedisService *service.RedisService
|
||||||
DescopeClient *client.DescopeClient
|
DescopeClient *client.DescopeClient
|
||||||
IdpProvider domain.IdentityProvider
|
IdpProvider domain.IdentityProvider
|
||||||
|
AuditRepo domain.AuditRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
type signupState struct {
|
type signupState struct {
|
||||||
@@ -127,7 +129,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
|
|||||||
return false, int(interval.Seconds())
|
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")
|
projectID := os.Getenv("DESCOPE_PROJECT_ID")
|
||||||
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
|
||||||
|
|
||||||
@@ -150,6 +152,7 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
|
|||||||
RedisService: redisService,
|
RedisService: redisService,
|
||||||
DescopeClient: descopeClient,
|
DescopeClient: descopeClient,
|
||||||
IdpProvider: idpProvider,
|
IdpProvider: idpProvider,
|
||||||
|
AuditRepo: auditRepo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -646,6 +649,10 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
userfrontURL = req.URI
|
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 != "" {
|
if init, err := h.IdpProvider.InitiateLinkLogin(lookupLoginID, userfrontURL); err == nil && init != nil && init.Mode != "" {
|
||||||
keyLoginID := lookupLoginID
|
keyLoginID := lookupLoginID
|
||||||
if init.LoginID != "" {
|
if init.LoginID != "" {
|
||||||
@@ -662,6 +669,12 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
pendingRef := GenerateSecureToken(3)
|
pendingRef := GenerateSecureToken(3)
|
||||||
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
|
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), loginCodeExpiration)
|
||||||
_ = h.RedisService.Set(prefixLoginCodePending+keyLoginID, pendingRef, 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 {
|
if !strings.Contains(loginID, "@") && keyLoginID != lookupLoginID {
|
||||||
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
|
_ = h.RedisService.Set(prefixLoginCodeSmsTarget+keyLoginID, lookupLoginID, loginCodeExpiration)
|
||||||
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
|
_ = h.RedisService.Set(prefixLoginCodeSmsLookup+lookupLoginID, keyLoginID, loginCodeExpiration)
|
||||||
@@ -698,6 +711,9 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
|||||||
// Store in Redis
|
// Store in Redis
|
||||||
h.RedisService.Set(prefixSession+pendingRef, fmt.Sprintf(`{"status":"%s"}`, statusPending), defaultExpiration)
|
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)
|
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
|
// Generate Link
|
||||||
slog.Info("[Enchanted] Read USERFRONT_URL", "url", userfrontURL)
|
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
|
// Route based on LoginID type
|
||||||
if strings.Contains(loginID, "@") {
|
if strings.Contains(loginID, "@") {
|
||||||
// Send Email
|
// Send Email
|
||||||
if h.EmailService == nil {
|
if !drySend && h.EmailService == nil {
|
||||||
slog.Error("[Enchanted] Email Service not configured")
|
slog.Error("[Enchanted] Email Service not configured")
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "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 {
|
|||||||
</div>
|
</div>
|
||||||
`, link, userCode)
|
`, link, userCode)
|
||||||
|
|
||||||
slog.Info("[Enchanted] Sending Email via AWS SES", "loginID", loginID)
|
if drySend {
|
||||||
if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
|
slog.Info("[Enchanted][DrySend] Email send skipped", "loginID", loginID, "link", link, "userCode", userCode)
|
||||||
slog.Error("[Enchanted] Email Failed", "error", err)
|
} else {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send Email"})
|
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 {
|
} else {
|
||||||
// Send SMS
|
// Send SMS
|
||||||
content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
content := fmt.Sprintf("[Baron 통합로그인] 로그인 링크: %s | 코드: %s", link, userCode)
|
||||||
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
if drySend {
|
||||||
|
slog.Info("[Enchanted][DrySend] SMS send skipped", "loginID", loginID, "content", content)
|
||||||
if err := h.SmsService.SendSms(loginID, content); err != nil {
|
} else {
|
||||||
slog.Error("[Enchanted] SMS Failed", "error", err)
|
slog.Info("[Enchanted] Sending SMS via Naver Cloud", "loginID", loginID)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
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.RedirectTo = resetLink
|
||||||
ale.Operation = "SendPasswordReset"
|
ale.Operation = "SendPasswordReset"
|
||||||
ale.Log(slog.LevelInfo, "Initiating password reset via internal token")
|
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 strings.Contains(loginID, "@") {
|
||||||
if h.EmailService == nil {
|
if !drySend && h.EmailService == nil {
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Status = fiber.StatusInternalServerError
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.LatencyMs = time.Since(startTime)
|
||||||
ale.DescopeError = "Email service not configured"
|
ale.DescopeError = "Email service not configured"
|
||||||
@@ -1161,20 +1188,29 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
|||||||
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
|
<p style="font-size: 12px; color: #888;">요청하지 않았다면 이 메일을 무시하세요.</p>
|
||||||
</div>
|
</div>
|
||||||
`, resetLink)
|
`, resetLink)
|
||||||
if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
|
if drySend {
|
||||||
ale.Status = fiber.StatusInternalServerError
|
ale.Log(slog.LevelInfo, "Email send skipped (dry-send)", slog.String("loginId", loginID), slog.String("link", resetLink))
|
||||||
ale.LatencyMs = time.Since(startTime)
|
} else {
|
||||||
ale.DescopeError = err.Error()
|
if err := h.EmailService.SendEmail(loginID, subject, body); err != nil {
|
||||||
ale.Log(slog.LevelError, "Failed to send reset email", slog.String("loginId", loginID))
|
ale.Status = fiber.StatusInternalServerError
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset email"})
|
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 {
|
} else {
|
||||||
if err := h.SmsService.SendSms(loginID, fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)); err != nil {
|
resetSms := fmt.Sprintf("[Baron 통합로그인] 비밀번호 재설정 링크: %s", resetLink)
|
||||||
ale.Status = fiber.StatusInternalServerError
|
if drySend {
|
||||||
ale.LatencyMs = time.Since(startTime)
|
ale.Log(slog.LevelInfo, "SMS send skipped (dry-send)", slog.String("loginId", loginID), slog.String("content", resetSms))
|
||||||
ale.DescopeError = err.Error()
|
} else {
|
||||||
ale.Log(slog.LevelError, "Failed to send reset SMS", slog.String("loginId", loginID))
|
if err := h.SmsService.SendSms(loginID, resetSms); err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send reset SMS"})
|
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, "@") {
|
if !strings.Contains(loginID, "@") {
|
||||||
loginID = normalizePhoneForLoginID(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 != "" {
|
if pendingRef, _ := h.RedisService.Get(prefixLoginCodeQrPending + loginID); pendingRef != "" {
|
||||||
code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
|
code := normalizeLoginCode(extractFirstString(req.TemplateData, "login_code"))
|
||||||
if code == "" {
|
if code == "" {
|
||||||
@@ -1617,6 +1661,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
if smsBody == "" {
|
if smsBody == "" {
|
||||||
smsBody = body
|
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 {
|
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
||||||
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
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 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"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Email service not configured"})
|
||||||
}
|
}
|
||||||
if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
|
if shortSubject, shortBody := h.buildKratosShortEmailBody(&req, req.Recipient); shortBody != "" {
|
||||||
subject = shortSubject
|
subject = shortSubject
|
||||||
body = shortBody
|
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 {
|
if err := h.EmailService.SendEmail(req.Recipient, subject, body); err != nil {
|
||||||
slog.Error("[Kratos Courier] Email send failed", "to", req.Recipient, "error", err)
|
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"})
|
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"})
|
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"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "SMS service not configured"})
|
||||||
}
|
}
|
||||||
phone := sanitizePhoneForSms(req.Recipient)
|
phone := sanitizePhoneForSms(req.Recipient)
|
||||||
@@ -1659,6 +1711,10 @@ func (h *AuthHandler) HandleKratosCourierRelay(c *fiber.Ctx) error {
|
|||||||
if smsBody == "" {
|
if smsBody == "" {
|
||||||
smsBody = body
|
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 {
|
if err := h.SmsService.SendSms(phone, smsBody); err != nil {
|
||||||
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
slog.Error("[Kratos Courier] SMS send failed", "to", phone, "error", err)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to send SMS"})
|
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
|
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) {
|
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)
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static String get _baseUrl => _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
|
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<Map<String, dynamic>> fetchPasswordPolicy() async {
|
static Future<Map<String, dynamic>> fetchPasswordPolicy() async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/policy');
|
||||||
@@ -45,6 +56,7 @@ class AuthProxyService {
|
|||||||
String loginId, {
|
String loginId, {
|
||||||
String? method,
|
String? method,
|
||||||
bool? codeOnly,
|
bool? codeOnly,
|
||||||
|
bool? drySend,
|
||||||
}) async {
|
}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/enchanted-link/init');
|
||||||
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
final userfrontUrl = _envOrDefault('USERFRONT_URL', 'http://sso.hmac.kr');
|
||||||
@@ -53,6 +65,9 @@ class AuthProxyService {
|
|||||||
'loginId': loginId,
|
'loginId': loginId,
|
||||||
'uri': userfrontUrl,
|
'uri': userfrontUrl,
|
||||||
};
|
};
|
||||||
|
if (_shouldSendDrySend(drySend)) {
|
||||||
|
body['drySend'] = true;
|
||||||
|
}
|
||||||
if (method != null) {
|
if (method != null) {
|
||||||
body['method'] = method;
|
body['method'] = method;
|
||||||
}
|
}
|
||||||
@@ -173,12 +188,15 @@ class AuthProxyService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId) async {
|
static Future<Map<String, dynamic>> initiatePasswordReset(String loginId, {bool? drySend}) async {
|
||||||
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
final url = Uri.parse('$_baseUrl/api/v1/auth/password/reset/initiate');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
url,
|
url,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({'loginId': loginId}),
|
body: jsonEncode({
|
||||||
|
'loginId': loginId,
|
||||||
|
if (_shouldSendDrySend(drySend)) 'drySend': true,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200) {
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ class ForgotPasswordScreen extends StatefulWidget {
|
|||||||
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||||
final TextEditingController _loginIdController = TextEditingController();
|
final TextEditingController _loginIdController = TextEditingController();
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
|
bool _drySendEnabled = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handlePasswordReset() async {
|
Future<void> _handlePasswordReset() async {
|
||||||
final input = _loginIdController.text.trim();
|
final input = _loginIdController.text.trim();
|
||||||
@@ -32,7 +39,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await AuthProxyService.initiatePasswordReset(loginId);
|
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -59,6 +66,14 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _parseBoolParam(String? value) {
|
||||||
|
if (value == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final normalized = value.toLowerCase();
|
||||||
|
return normalized == 'true' || normalized == '1' || normalized == 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -82,6 +97,29 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
|||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
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 SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
|
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
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:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.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';
|
||||||
@@ -47,6 +46,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
Timer? _linkResendTimer;
|
Timer? _linkResendTimer;
|
||||||
int _linkExpireSeconds = 0;
|
int _linkExpireSeconds = 0;
|
||||||
Timer? _linkExpireTimer;
|
Timer? _linkExpireTimer;
|
||||||
|
bool _verificationOnly = false;
|
||||||
|
bool _drySendEnabled = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -54,6 +55,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
|
// 탭 컨트롤러: 3개 탭, 기본 선택은 두 번째 탭("로그인 링크")
|
||||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||||
_tabController.addListener(_handleTabSelection);
|
_tabController.addListener(_handleTabSelection);
|
||||||
|
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
||||||
|
|
||||||
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
// Check for tokens (Path Parameter or Legacy Query Parameter)
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -61,19 +63,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final loginIdParam = uri.queryParameters['loginId'];
|
final loginIdParam = uri.queryParameters['loginId'];
|
||||||
final codeParam = uri.queryParameters['code'];
|
final codeParam = uri.queryParameters['code'];
|
||||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
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];
|
final shortCode = uri.pathSegments[1];
|
||||||
_verifyShortCode(shortCode);
|
_verifyShortCode(shortCode);
|
||||||
}
|
}
|
||||||
if (loginIdParam != null && codeParam != null) {
|
if (hasLoginCode) {
|
||||||
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
_verifyLoginCode(loginIdParam, codeParam, pendingRef: pendingRefParam);
|
||||||
} else if (widget.verificationToken != null) {
|
} else if (hasVerificationToken) {
|
||||||
_verifyToken(widget.verificationToken!);
|
_verifyToken(widget.verificationToken ?? uri.queryParameters['t']!);
|
||||||
} else if (uri.queryParameters.containsKey('t')) {
|
|
||||||
_verifyToken(uri.queryParameters['t']!);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_tryCookieSession();
|
if (!_verificationOnly) {
|
||||||
|
_tryCookieSession();
|
||||||
|
}
|
||||||
|
|
||||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||||
@@ -128,6 +136,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
_shortCodeDigitsController.clear();
|
_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) {
|
void _startLinkResendTimer(int seconds) {
|
||||||
_linkResendSeconds = seconds;
|
_linkResendSeconds = seconds;
|
||||||
_linkResendTimer?.cancel();
|
_linkResendTimer?.cancel();
|
||||||
@@ -284,40 +300,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res['status'] == 'ok' && res['sessionJwt'] != null) {
|
if (res['status'] == 'ok') {
|
||||||
timer.cancel();
|
timer.cancel();
|
||||||
_qrCountdownTimer?.cancel();
|
_qrCountdownTimer?.cancel();
|
||||||
|
final token = res['sessionJwt'] ?? res['token'];
|
||||||
final token = res['sessionJwt'] as String;
|
if (token is String && token.isNotEmpty) {
|
||||||
final isJwt = token.split('.').length == 3;
|
_completeLoginFromToken(token);
|
||||||
if (isJwt) {
|
} else {
|
||||||
final displayName = _getLoginIdFromJwt(token);
|
_showError("로그인 토큰을 확인할 수 없습니다.");
|
||||||
// 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<String>)
|
|
||||||
);
|
|
||||||
final session = DescopeSession.fromJwt(token, token, dummyUser);
|
|
||||||
Descope.sessionManager.manageSession(session);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_onLoginSuccess(token);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[QR] Polling error: $e");
|
debugPrint("[QR] Polling error: $e");
|
||||||
@@ -339,26 +330,37 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return "${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}";
|
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<void> _verifyToken(String token) async {
|
Future<void> _verifyToken(String token) async {
|
||||||
debugPrint("[Auth] Starting verification for token: $token");
|
debugPrint("[Auth] Starting verification for token: $token");
|
||||||
try {
|
try {
|
||||||
// Use Backend to verify the token (Backend-Driven Flow)
|
// Use Backend to verify the token (Backend-Driven Flow)
|
||||||
final res = await AuthProxyService.verifyMagicLink(token);
|
await AuthProxyService.verifyMagicLink(token);
|
||||||
final jwt = res['token'];
|
|
||||||
debugPrint("[Auth] Verification successful for token: $token");
|
debugPrint("[Auth] Verification successful for token: $token");
|
||||||
|
|
||||||
if (jwt != null && mounted) {
|
if (mounted) {
|
||||||
final displayName = _getLoginIdFromJwt(jwt);
|
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||||
@@ -388,17 +390,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwt != null && mounted) {
|
if (_verificationOnly) {
|
||||||
final isJwt = (jwt as String).split('.').length == 3;
|
if (mounted) {
|
||||||
if (isJwt) {
|
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
||||||
@@ -425,17 +425,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (jwt != null && mounted) {
|
if (_verificationOnly) {
|
||||||
final isJwt = (jwt as String).split('.').length == 3;
|
if (mounted) {
|
||||||
if (isJwt) {
|
_showInfo("승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.");
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
_onLoginSuccess(jwt, provider: res['provider'] as String?);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jwt is String && jwt.isNotEmpty) {
|
||||||
|
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||||
@@ -543,6 +541,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
final initResponse = await AuthProxyService.initEnchantedLink(
|
final initResponse = await AuthProxyService.initEnchantedLink(
|
||||||
loginId,
|
loginId,
|
||||||
codeOnly: codeOnly,
|
codeOnly: codeOnly,
|
||||||
|
drySend: _drySendEnabled,
|
||||||
);
|
);
|
||||||
final pendingRef = initResponse['pendingRef'];
|
final pendingRef = initResponse['pendingRef'];
|
||||||
final mode = (initResponse['mode'] ?? '').toString();
|
final mode = (initResponse['mode'] ?? '').toString();
|
||||||
@@ -627,24 +626,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (result['status'] == 'ok') {
|
if (result['status'] == 'ok') {
|
||||||
final jwt = result['sessionJwt'];
|
final token = result['sessionJwt'] ?? result['token'];
|
||||||
if (jwt != null) {
|
if (token is String && token.isNotEmpty) {
|
||||||
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
debugPrint("[Auth] Polling SUCCESS. Token received.");
|
||||||
|
_completeLoginFromToken(
|
||||||
final displayName = _getLoginIdFromJwt(jwt);
|
token,
|
||||||
// Descope SDK 세션 강제 주입
|
provider: result['provider'] as String?,
|
||||||
final dummyUser = DescopeUser(
|
closeDialog: true,
|
||||||
'unknown', [], 0, displayName, null, '', false, '', false, {}, '', '', '', false, 'enabled', [], [], [],
|
|
||||||
);
|
);
|
||||||
final session = DescopeSession.fromJwt(jwt, jwt, dummyUser);
|
|
||||||
Descope.sessionManager.manageSession(session);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
Navigator.of(context).pop(); // Close Polling Dialog
|
|
||||||
_onLoginSuccess(jwt);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
debugPrint("[Auth] Polling SUCCESS but token missing.");
|
||||||
|
if (mounted && Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
_showError("로그인 토큰을 확인할 수 없습니다.");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint("[Auth] Polling error (attempt $attempts): $e");
|
debugPrint("[Auth] Polling error (attempt $attempts): $e");
|
||||||
@@ -802,13 +799,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Baron SSO",
|
"Baron 통합로그인",
|
||||||
style: GoogleFonts.outfit(
|
style: GoogleFonts.outfit(
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
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),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
TabBar(
|
TabBar(
|
||||||
|
|||||||
@@ -1,29 +1,227 @@
|
|||||||
|
import 'dart:convert';
|
||||||
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:descope/descope.dart';
|
import 'package:descope/descope.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
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 '../../profile/domain/notifiers/profile_notifier.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<String, dynamic> 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<String, dynamic> get detailMap {
|
||||||
|
if (details.isEmpty) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return jsonDecode(details) as Map<String, dynamic>;
|
||||||
|
} 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});
|
const DashboardScreen({super.key});
|
||||||
|
|
||||||
Future<void> _logout(BuildContext context) async {
|
@override
|
||||||
// ignore: use_build_context_synchronously
|
ConsumerState<DashboardScreen> createState() => _DashboardScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
||||||
|
static const _ink = Color(0xFF1A1F2C);
|
||||||
|
static const _surface = Colors.white;
|
||||||
|
static const _border = Color(0xFFE5E7EB);
|
||||||
|
static const _subtle = Color(0xFFF7F8FA);
|
||||||
|
|
||||||
|
Future<List<AuditLogEntry>>? _auditFuture;
|
||||||
|
bool _showAllActivities = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_auditFuture = _fetchAuditLogs();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _logout() async {
|
||||||
Descope.sessionManager.clearSession();
|
Descope.sessionManager.clearSession();
|
||||||
AuthTokenStore.clear();
|
AuthTokenStore.clear();
|
||||||
AuthNotifier.instance.notify();
|
AuthNotifier.instance.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScanQR(BuildContext context) {
|
void _onScanQR() {
|
||||||
context.push('/scan');
|
context.push('/scan');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<List<AuditLogEntry>> _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 = <String, String>{
|
||||||
|
'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<String, dynamic>;
|
||||||
|
final items = (body['items'] as List?) ?? [];
|
||||||
|
final logs = items
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.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<String, dynamic>;
|
||||||
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final profile = ref.watch(profileProvider).value;
|
final profileState = ref.watch(profileProvider);
|
||||||
|
final profile = profileState.value;
|
||||||
final user = Descope.sessionManager.session?.user;
|
final user = Descope.sessionManager.session?.user;
|
||||||
final userName = user?.name ??
|
final userName = user?.name ??
|
||||||
user?.email ??
|
user?.email ??
|
||||||
@@ -32,98 +230,503 @@ class DashboardScreen extends ConsumerWidget {
|
|||||||
profile?.email ??
|
profile?.email ??
|
||||||
profile?.phone ??
|
profile?.phone ??
|
||||||
'User';
|
'User';
|
||||||
|
final department = profile?.department.isNotEmpty == true ? profile!.department : '소속 정보 없음';
|
||||||
|
final sessionIssuedAt = _getJwtIssuedAt();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.grey[50],
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text('Baron SSO', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)),
|
title: Text(
|
||||||
|
'Baron 통합로그인',
|
||||||
|
style: GoogleFonts.outfit(fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: _surface,
|
||||||
foregroundColor: Colors.black,
|
foregroundColor: Colors.black,
|
||||||
actions: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
onPressed: () => _logout(context),
|
tooltip: '로그아웃',
|
||||||
tooltip: 'Sign Out',
|
onPressed: _logout,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Center(
|
drawer: Drawer(
|
||||||
child: Padding(
|
child: SafeArea(
|
||||||
padding: const EdgeInsets.all(24.0),
|
child: ListView(
|
||||||
child: Column(
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
|
ListTile(
|
||||||
const SizedBox(height: 24),
|
leading: const Icon(Icons.person_outline),
|
||||||
Text(
|
title: const Text('내 정보'),
|
||||||
'로그인 성공!',
|
onTap: () {
|
||||||
style: GoogleFonts.notoSans(
|
Navigator.of(context).pop();
|
||||||
fontSize: 28,
|
context.push('/profile');
|
||||||
fontWeight: FontWeight.bold,
|
},
|
||||||
color: const Color(0xFF1A1F2C),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
ListTile(
|
||||||
Text(
|
leading: const Icon(Icons.qr_code_scanner),
|
||||||
'반갑습니다, $userName님',
|
title: const Text('QR 스캔'),
|
||||||
style: GoogleFonts.notoSans(
|
onTap: () {
|
||||||
fontSize: 16,
|
Navigator.of(context).pop();
|
||||||
color: Colors.grey[600],
|
_onScanQR();
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
const Divider(),
|
||||||
|
ListTile(
|
||||||
// QR Camera Button
|
leading: const Icon(Icons.logout),
|
||||||
SizedBox(
|
title: const Text('로그아웃'),
|
||||||
width: double.infinity,
|
onTap: () async {
|
||||||
height: 56,
|
Navigator.of(context).pop();
|
||||||
child: ElevatedButton(
|
await _logout();
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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<List<AuditLogEntry>>(
|
||||||
|
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<AuditLogEntry> 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<AuditLogEntry> 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ class BaronSSOApp extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MaterialApp.router(
|
return MaterialApp.router(
|
||||||
title: 'Baron SSO',
|
title: 'Baron 통합로그인',
|
||||||
theme: ThemeData(
|
theme: ThemeData(
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
||||||
|
|||||||
@@ -23,13 +23,13 @@
|
|||||||
<!-- iOS meta tags & icons -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
<meta name="apple-mobile-web-app-title" content="userfront">
|
<meta name="apple-mobile-web-app-title" content="Baron 통합로그인">
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>userfront</title>
|
<title>Baron 통합로그인</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "userfront",
|
"name": "Baron 통합로그인",
|
||||||
"short_name": "userfront",
|
"short_name": "Baron 통합로그인",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"theme_color": "#0175C2",
|
"theme_color": "#0175C2",
|
||||||
"description": "A new Flutter project.",
|
"description": "Baron 통합로그인 사용자 포털.",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
"prefer_related_applications": false,
|
"prefer_related_applications": false,
|
||||||
"icons": [
|
"icons": [
|
||||||
|
|||||||
Reference in New Issue
Block a user