1
0
forked from baron/baron-sso

consent 변화 내부 머지 완료

This commit is contained in:
Lectom C Han
2026-02-02 17:13:18 +09:00
parent e345570210
commit c94a369d1d
7 changed files with 482 additions and 96 deletions

View File

@@ -159,6 +159,20 @@ func main() {
slog.Info("✅ Connected to ClickHouse")
}
var oathkeeperRepo domain.OathkeeperLogRepository
oryCHHost := getEnv("ORY_CLICKHOUSE_HOST", "ory_clickhouse")
oryCHPort, _ := strconv.Atoi(getEnv("ORY_CLICKHOUSE_PORT_NATIVE", "9000"))
oryCHUser := getEnv("ORY_CLICKHOUSE_USER", "ory")
oryCHPass := getEnv("ORY_CLICKHOUSE_PASSWORD", "orypass")
oryCHDB := getEnv("ORY_CLICKHOUSE_DB", "ory")
if repo, err := repository.NewOathkeeperClickHouseRepository(oryCHHost, oryCHPort, oryCHUser, oryCHPass, oryCHDB); err != nil {
slog.Warn("Failed to connect to Ory ClickHouse. Oathkeeper logs will be skipped.", "error", err)
oathkeeperRepo = nil
} else {
oathkeeperRepo = repo
slog.Info("✅ Connected to Ory ClickHouse")
}
// PostgreSQL (Meta Store)
pgHost := getEnv("DB_HOST", "localhost")
pgPort := getEnv("DB_PORT", "5432")
@@ -228,7 +242,7 @@ func main() {
userRepo := repository.NewUserRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, tenantService, userRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, userRepo)
adminHandler := handler.NewAdminHandler()
devHandler := handler.NewDevHandler(redisService)
tenantHandler := handler.NewTenantHandler(db, tenantService)

View File

@@ -80,17 +80,18 @@ const (
)
type AuthHandler struct {
ProjectID string
SmsService domain.SmsService
EmailService domain.EmailService
RedisService *service.RedisService
DescopeClient *client.DescopeClient
KratosAdmin *service.KratosAdminService
IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
UserRepo repository.UserRepository
ProjectID string
SmsService domain.SmsService
EmailService domain.EmailService
RedisService *service.RedisService
DescopeClient *client.DescopeClient
KratosAdmin *service.KratosAdminService
IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService
TenantService service.TenantService
UserRepo repository.UserRepository
}
type signupState struct {
@@ -148,7 +149,7 @@ func checkPollInterval(redis *service.RedisService, key string, interval time.Du
return false, int(interval.Seconds())
}
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
func NewAuthHandler(redisService *service.RedisService, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, userRepo repository.UserRepository) *AuthHandler {
projectID := os.Getenv("DESCOPE_PROJECT_ID")
managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY")
@@ -165,17 +166,18 @@ func NewAuthHandler(redisService *service.RedisService, idpProvider domain.Ident
}
return &AuthHandler{
ProjectID: projectID,
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
DescopeClient: descopeClient,
KratosAdmin: service.NewKratosAdminService(),
IdpProvider: idpProvider,
AuditRepo: auditRepo,
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
UserRepo: userRepo,
ProjectID: projectID,
SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(),
RedisService: redisService,
DescopeClient: descopeClient,
KratosAdmin: service.NewKratosAdminService(),
IdpProvider: idpProvider,
AuditRepo: auditRepo,
OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(),
TenantService: tenantService,
UserRepo: userRepo,
}
}
@@ -1155,8 +1157,8 @@ func (h *AuthHandler) VerifyLoginCode(c *fiber.Ctx) error {
// VerifyLoginShortCode - Verify short code (2 letters + 6 digits) and issue/approve session.
func (h *AuthHandler) VerifyLoginShortCode(c *fiber.Ctx) error {
var req struct {
ShortCode string `json:"shortCode"`
VerifyOnly bool `json:"verifyOnly,omitempty"`
ShortCode string `json:"shortCode"`
VerifyOnly bool `json:"verifyOnly,omitempty"`
}
if err := c.BodyParser(&req); err != nil {
slog.Error("[LoginShortCode] Body parse error", "error", err)
@@ -2700,8 +2702,31 @@ func extractClientIPFromHeaders(c *fiber.Ctx) string {
return c.IP()
}
type authTimelineItem struct {
EventID string `json:"event_id"`
Timestamp time.Time `json:"timestamp"`
UserID string `json:"user_id"`
SessionID string `json:"session_id,omitempty"`
EventType string `json:"event_type"`
Status string `json:"status"`
AuthMethod string `json:"auth_method,omitempty"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
Details string `json:"details,omitempty"`
Source string `json:"source,omitempty"`
ClientID string `json:"client_id,omitempty"`
AppName string `json:"app_name,omitempty"`
ParentSessionID string `json:"parent_session_id,omitempty"`
}
type consentClientInfo struct {
ClientID string
Name string
ConsentAt time.Time
}
func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
if h.AuditRepo == nil {
if h.AuditRepo == nil && h.OathkeeperRepo == nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{"error": "Audit service unavailable"})
}
@@ -2728,6 +2753,51 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"error": "Invalid session"})
}
subject := ""
if h.OathkeeperRepo != nil {
if value, err := h.resolveConsentSubject(c); err == nil {
subject = value
}
}
consentMap := make(map[string]consentClientInfo)
if subject != "" && h.Hydra != nil {
if sessions, err := h.Hydra.ListConsentSessions(c.Context(), subject, ""); err == nil {
for _, session := range sessions {
clientID := strings.TrimSpace(session.Client.ClientID)
if clientID == "" {
continue
}
name := strings.TrimSpace(session.Client.ClientName)
if name == "" {
name = clientID
}
consentAt := time.Time{}
if session.AuthenticatedAt != nil {
consentAt = *session.AuthenticatedAt
} else if session.RequestedAt != nil {
consentAt = *session.RequestedAt
}
if existing, ok := consentMap[clientID]; ok {
if !consentAt.IsZero() && (existing.ConsentAt.IsZero() || consentAt.Before(existing.ConsentAt)) {
existing.ConsentAt = consentAt
consentMap[clientID] = existing
}
if existing.Name == "" {
existing.Name = name
consentMap[clientID] = existing
}
continue
}
consentMap[clientID] = consentClientInfo{
ClientID: clientID,
Name: name,
ConsentAt: consentAt,
}
}
}
}
candidates := buildLoginCandidates(profile)
fetchLimit := limit * 10
if fetchLimit < limit {
@@ -2737,69 +2807,177 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
fetchLimit = 500
}
items := make([]domain.AuditLog, 0, limit)
nextCursor := ""
currentCursor := cursor
const maxBatches = 10
for batch := 0; batch < maxBatches && len(items) < limit; batch++ {
logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
}
if len(logs) == 0 {
nextCursor = ""
break
}
var lastScanned *domain.AuditLog
for i := range logs {
log := logs[i]
lastScanned = &log
if !isAuthEventType(log.EventType) {
continue
authLogs := make([]domain.AuditLog, 0, fetchLimit)
if h.AuditRepo != nil {
currentCursor := cursor
const maxBatches = 10
for batch := 0; batch < maxBatches && len(authLogs) < fetchLimit; batch++ {
logs, err := h.AuditRepo.FindPage(c.Context(), fetchLimit, currentCursor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve audit logs"})
}
if !matchesAuthTimelineUser(log, profile, candidates) {
continue
}
if shouldSkipAuthTimeline(log) {
continue
}
if log.UserID == "" {
log.UserID = profile.ID
}
log.AuthMethod = deriveAuthMethod(log)
if log.AuthMethod == "" {
continue
}
if log.SessionID == "" {
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
}
items = append(items, log)
if len(items) >= limit {
nextCursor = encodeAuditCursor(log)
if len(logs) == 0 {
break
}
}
if len(items) >= limit {
break
}
var lastScanned *domain.AuditLog
for i := range logs {
log := logs[i]
lastScanned = &log
if !isAuthEventType(log.EventType) {
continue
}
if !matchesAuthTimelineUser(log, profile, candidates) {
continue
}
if shouldSkipAuthTimeline(log) {
continue
}
if log.UserID == "" {
log.UserID = profile.ID
}
log.AuthMethod = deriveAuthMethod(log)
if log.AuthMethod == "" {
continue
}
if log.SessionID == "" {
log.SessionID = extractSessionIDFromAuditDetails(log.Details)
}
authLogs = append(authLogs, log)
if len(authLogs) >= fetchLimit {
break
}
}
if len(logs) < fetchLimit {
nextCursor = ""
break
if len(logs) < fetchLimit || lastScanned == nil {
break
}
currentCursor = &domain.AuditCursor{
Timestamp: lastScanned.Timestamp,
EventID: lastScanned.EventID,
}
}
}
if lastScanned == nil {
nextCursor = ""
break
}
oathkeeperLogs := make([]domain.OathkeeperAccessLog, 0, fetchLimit)
if h.OathkeeperRepo != nil && subject != "" {
currentCursor := cursor
const maxBatches = 10
for batch := 0; batch < maxBatches && len(oathkeeperLogs) < fetchLimit; batch++ {
logs, err := h.OathkeeperRepo.FindPageBySubject(c.Context(), subject, fetchLimit, currentCursor)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "Failed to retrieve oathkeeper logs"})
}
if len(logs) == 0 {
break
}
var lastScanned *domain.OathkeeperAccessLog
for i := range logs {
log := logs[i]
lastScanned = &log
clientID := extractClientIDFromOathkeeperLog(log)
if clientID == "" {
continue
}
consent, ok := consentMap[clientID]
if !ok {
continue
}
if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
continue
}
oathkeeperLogs = append(oathkeeperLogs, log)
if len(oathkeeperLogs) >= fetchLimit {
break
}
}
currentCursor = &domain.AuditCursor{
Timestamp: lastScanned.Timestamp,
EventID: lastScanned.EventID,
if len(logs) < fetchLimit || lastScanned == nil {
break
}
currentCursor = &domain.AuditCursor{
Timestamp: lastScanned.Timestamp,
EventID: oathkeeperEventID(*lastScanned),
}
}
nextCursor = encodeAuditCursor(*lastScanned)
}
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
for i := range authLogs {
log := authLogs[i]
item := authTimelineItem{
EventID: log.EventID,
Timestamp: log.Timestamp,
UserID: log.UserID,
SessionID: log.SessionID,
EventType: log.EventType,
Status: log.Status,
AuthMethod: log.AuthMethod,
IPAddress: log.IPAddress,
UserAgent: log.UserAgent,
Details: log.Details,
Source: "backend",
AppName: "Baron 통합로그인",
}
items = append(items, item)
}
for i := range oathkeeperLogs {
log := oathkeeperLogs[i]
clientID := extractClientIDFromOathkeeperLog(log)
if clientID == "" {
continue
}
consent := consentMap[clientID]
appName := consent.Name
if appName == "" {
appName = clientID
}
details := map[string]any{
"path": log.Path,
"client_id": clientID,
"decision": log.Decision,
"status_code": log.Status,
}
detailsJSON, _ := json.Marshal(details)
status := "success"
if log.Status >= 400 {
status = "failure"
}
eventID := oathkeeperEventID(log)
item := authTimelineItem{
EventID: eventID,
Timestamp: log.Timestamp,
UserID: profile.ID,
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
Status: status,
AuthMethod: "세션 위임",
IPAddress: log.ClientIP,
UserAgent: log.UserAgent,
Details: string(detailsJSON),
Source: "oathkeeper",
ClientID: clientID,
AppName: appName,
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].Timestamp.Equal(items[j].Timestamp) {
return items[i].EventID > items[j].EventID
}
return items[i].Timestamp.After(items[j].Timestamp)
})
nextCursor := ""
hasMore := len(authLogs) >= fetchLimit || len(oathkeeperLogs) >= fetchLimit
if len(items) > limit {
items = items[:limit]
last := items[len(items)-1]
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
} else if hasMore && len(items) > 0 {
last := items[len(items)-1]
nextCursor = encodeTimelineCursor(last.Timestamp, last.EventID)
}
return c.JSON(fiber.Map{
@@ -2810,6 +2988,27 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
})
}
func encodeTimelineCursor(timestamp time.Time, eventID string) string {
if eventID == "" {
eventID = fmt.Sprintf("%d", timestamp.UnixNano())
}
payload := timestamp.UTC().Format(time.RFC3339Nano) + "|" + eventID
return base64.RawURLEncoding.EncodeToString([]byte(payload))
}
func oathkeeperEventID(log domain.OathkeeperAccessLog) string {
if log.RequestID != "" {
return log.RequestID
}
if log.TraceID != "" {
return log.TraceID
}
if log.SpanID != "" {
return log.SpanID
}
return fmt.Sprintf("%d", log.Timestamp.UnixNano())
}
type linkedRpSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -3365,6 +3564,80 @@ func extractLoginIDFromAuditDetails(details string) string {
return ""
}
func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
if value := strings.TrimSpace(log.RP); value != "" {
return value
}
if value := parseClientIDFromURL(log.Target); value != "" {
return value
}
if value := parseClientIDFromURL(log.Path); value != "" {
return value
}
return parseClientIDFromRaw(log.Raw)
}
func parseClientIDFromURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
if id := strings.TrimSpace(parsed.Query().Get("client_id")); id != "" {
return id
}
if id := strings.TrimSpace(parsed.Query().Get("clientId")); id != "" {
return id
}
return ""
}
func parseClientIDFromRaw(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
var payload map[string]any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return ""
}
if id := readClientIDFromPayload(payload); id != "" {
return id
}
if request, ok := payload["request"].(map[string]any); ok {
if id := readClientIDFromPayload(request); id != "" {
return id
}
if urlRaw, ok := request["url"].(string); ok {
if id := parseClientIDFromURL(urlRaw); id != "" {
return id
}
}
if pathRaw, ok := request["path"].(string); ok {
if id := parseClientIDFromURL(pathRaw); id != "" {
return id
}
}
}
return ""
}
func readClientIDFromPayload(payload map[string]any) string {
if payload == nil {
return ""
}
if raw, ok := payload["client_id"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw)
}
if raw, ok := payload["clientId"].(string); ok && strings.TrimSpace(raw) != "" {
return strings.TrimSpace(raw)
}
return ""
}
func extractSessionIDFromAuditDetails(details string) string {
if details == "" {
return ""

View File

@@ -7,13 +7,23 @@ CREATE TABLE IF NOT EXISTS ory.oathkeeper_access_logs (
path String DEFAULT '',
status UInt16 DEFAULT 0,
latency_ms UInt32 DEFAULT 0,
client_id String DEFAULT '',
rp String DEFAULT '',
action String DEFAULT '',
target String DEFAULT '',
rule_id String DEFAULT '',
host String DEFAULT '',
scheme String DEFAULT '',
query String DEFAULT '',
upstream_url String DEFAULT '',
subject String DEFAULT '',
parent_session_id String DEFAULT '',
client_ip String DEFAULT '',
user_agent String DEFAULT '',
referer String DEFAULT '',
decision String DEFAULT '',
bytes_in UInt64 DEFAULT 0,
bytes_out UInt64 DEFAULT 0,
trace_id String DEFAULT '',
span_id String DEFAULT '',
raw String DEFAULT ''

View File

@@ -84,5 +84,33 @@
"authenticators": [{ "handler": "noop" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "rp-template-browser",
"description": "RP proxy (browser session). TODO: match.url/upstream.url을 실제 RP로 좁혀야 함.",
"match": {
"url": "http://<.*>/rp/<.*>",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
},
"upstream": {
"url": "http://rp_upstream:8080"
},
"authenticators": [{ "handler": "cookie_session" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
},
{
"id": "rp-template-bearer",
"description": "RP proxy (bearer). TODO: oauth2_introspection 또는 jwt 활성화 필요.",
"match": {
"url": "http://<.*>/rp-api/<.*>",
"methods": ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
},
"upstream": {
"url": "http://rp_upstream:8080"
},
"authenticators": [{ "handler": "oauth2_introspection" }],
"authorizer": { "handler": "allow" },
"mutators": [{ "handler": "noop" }]
}
]

View File

@@ -15,6 +15,9 @@
request_method = get(parsed, ["request", "method"]) ?? ""
request_path = get(parsed, ["request", "path"]) ?? ""
request_url = get(parsed, ["request", "url"]) ?? ""
request_host = get(parsed, ["request", "host"]) ?? ""
request_scheme = get(parsed, ["request", "scheme"]) ?? ""
request_query = get(parsed, ["request", "query"]) ?? ""
.method = parsed.method ?? parsed.http_method ?? request_method ?? ""
.path = parsed.path ?? parsed.http_path ?? request_path ?? request_url ?? ""
response_status = get(parsed, ["response", "status"]) ?? 0
@@ -27,6 +30,7 @@
.user_agent = parsed.user_agent
if is_null(.user_agent) { .user_agent = get(headers, ["User-Agent"]) }
if is_null(.user_agent) { .user_agent = "" }
.referer = get(headers, ["Referer"]) ?? ""
.decision = parsed.decision
if is_null(.decision) { .decision = parsed.result }
@@ -38,9 +42,18 @@
.span_id = parsed.span_id
if is_null(.span_id) { .span_id = "" }
.rp = ""
.action = ""
.target = ""
.rp = parsed.rp ?? ""
.action = parsed.action ?? ""
.target = parsed.target ?? ""
.rule_id = parsed.rule_id ?? get(parsed, ["rule", "id"]) ?? ""
.client_id = parsed.client_id ?? get(parsed, ["client", "id"]) ?? ""
.parent_session_id = parsed.parent_session_id ?? get(parsed, ["extra", "parent_session_id"]) ?? ""
.host = parsed.host ?? request_host ?? ""
.scheme = parsed.scheme ?? request_scheme ?? ""
.query = parsed.query ?? request_query ?? ""
.upstream_url = parsed.upstream_url ?? get(parsed, ["upstream", "url"]) ?? ""
.bytes_in = to_int(parsed.bytes_in ?? parsed.request_bytes ?? 0) ?? 0
.bytes_out = to_int(parsed.bytes_out ?? parsed.response_bytes ?? 0) ?? 0
'''
[sinks.clickhouse]

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart';
import '../../../core/services/auth_proxy_service.dart';
class ErrorScreen extends StatelessWidget {
final String? errorId;
@@ -16,15 +18,22 @@ class ErrorScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final errorType = (errorCode == null || errorCode!.isEmpty)
? 'unknown_error'
: errorCode!;
final title = errorCode == null || errorCode!.isEmpty
final isProd = AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
final whitelistMessage = errorWhitelistMessages[normalizedCode];
final isWhitelisted = whitelistMessage != null;
final errorType = isProd
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error');
final title = isProd
? '인증 과정에서 오류가 발생했습니다'
: '오류: $errorCode';
final detail = description?.isNotEmpty == true
? description!
: '요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도해 주세요.';
: (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다');
final detail = isProd
? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요')
: ((description?.isNotEmpty == true)
? description!
: (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.'));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),

View File

@@ -21,6 +21,10 @@ class AuditLogEntry {
final String userAgent;
final String sessionId;
final String details;
final String source;
final String clientId;
final String appName;
final String parentSessionId;
AuditLogEntry({
required this.eventId,
@@ -33,6 +37,10 @@ class AuditLogEntry {
required this.userAgent,
required this.sessionId,
required this.details,
required this.source,
required this.clientId,
required this.appName,
required this.parentSessionId,
});
factory AuditLogEntry.fromJson(Map<String, dynamic> json) {
@@ -55,6 +63,10 @@ class AuditLogEntry {
userAgent: json['user_agent'] ?? '',
sessionId: json['session_id'] ?? '',
details: json['details'] ?? '',
source: json['source'] ?? '',
clientId: json['client_id'] ?? '',
appName: json['app_name'] ?? '',
parentSessionId: json['parent_session_id'] ?? '',
);
}
@@ -542,6 +554,34 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
String _appLabelForLog(AuditLogEntry log) {
if (log.appName.isNotEmpty) {
return log.appName;
}
return _appLabelForPath(log.path);
}
Widget _buildAppCell(AuditLogEntry log, {TextStyle? style}) {
final label = _appLabelForLog(log);
if (label == 'Baron 통합로그인') {
return _selectableText(label, style: style);
}
final tooltip = log.parentSessionId.isEmpty
? '부모 세션 ID 없음'
: '부모 세션 ID: ${log.parentSessionId}';
final baseStyle = style ?? const TextStyle();
final emphasisStyle = log.parentSessionId.isEmpty
? baseStyle
: baseStyle.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
);
return Tooltip(
message: tooltip,
child: _selectableText(label, style: emphasisStyle),
);
}
String _appLabelForPath(String path) {
if (path.startsWith('/api/v1/auth')) {
return 'Baron 통합로그인';
@@ -992,13 +1032,12 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
rows: logs.map((log) {
final statusLabel = log.status == 'success' ? '성공' : '실패';
final statusColor = log.status == 'success' ? Colors.green : Colors.redAccent;
final appLabel = _appLabelForPath(log.path);
final authMethod = log.authMethod.isNotEmpty ? log.authMethod : _authMethodLabel();
final deviceLabel = _deviceLabelFromUserAgent(log.userAgent);
return DataRow(cells: [
DataCell(_selectableText(log.sessionId.isEmpty ? '-' : log.sessionId)),
DataCell(_selectableText(_formatDateTime(log.timestamp))),
DataCell(_selectableText(appLabel)),
DataCell(_buildAppCell(log)),
DataCell(_selectableText(log.ipAddress.isEmpty ? '-' : log.ipAddress)),
DataCell(_selectableText(deviceLabel)),
DataCell(_buildAuthMethodCell(log, authMethod)),
@@ -1036,8 +1075,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
Row(
children: [
Expanded(
child: _selectableText(
_appLabelForPath(log.path),
child: _buildAppCell(
log,
style: const TextStyle(fontWeight: FontWeight.w600, color: _ink),
),
),