1
0
forked from baron/baron-sso

사용자 활성 세션 조회·종료 API 추가

This commit is contained in:
2026-04-02 11:01:23 +09:00
parent cdf2c36915
commit a2f2b2dd71
15 changed files with 1922 additions and 1 deletions

View File

@@ -581,6 +581,8 @@ func main() {
user.Post("/me/password", authHandler.ChangeMyPassword)
user.Post("/me/send-code", authHandler.SendUpdateCode)
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
user.Get("/sessions", authHandler.ListMySessions)
user.Delete("/sessions/:id", authHandler.DeleteMySession)
user.Get("/rp/linked", authHandler.ListLinkedRps)
user.Get("/rp/history", authHandler.ListRpHistory)
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)

View File

@@ -6664,6 +6664,167 @@ type rpHistoryItem struct {
Status string `json:"status"`
}
type userSessionItem struct {
SessionID string `json:"session_id"`
AuthenticatedAt *time.Time `json:"authenticated_at,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
IssuedAt *time.Time `json:"issued_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
UserAgent string `json:"user_agent,omitempty"`
ClientID string `json:"client_id,omitempty"`
AppName string `json:"app_name,omitempty"`
IsCurrent bool `json:"is_current"`
IsActive bool `json:"is_active"`
}
type userSessionListResponse struct {
Items []userSessionItem `json:"items"`
}
func (h *AuthHandler) ListMySessions(c *fiber.Ctx) error {
if h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
}
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
if strings.TrimSpace(profile.ID) == "" {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
sessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
}
currentSessionID := h.resolveCurrentSessionID(c)
auditHints := h.loadSessionAuditHints(c.Context(), profile.ID)
items := make([]userSessionItem, 0, len(sessions))
for _, session := range sessions {
if !session.Active {
continue
}
item := userSessionItem{
SessionID: session.ID,
IsCurrent: session.ID != "" && session.ID == currentSessionID,
IsActive: session.Active,
}
if !session.AuthenticatedAt.IsZero() {
ts := session.AuthenticatedAt
item.AuthenticatedAt = &ts
item.LastSeenAt = &ts
}
if !session.ExpiresAt.IsZero() {
ts := session.ExpiresAt
item.ExpiresAt = &ts
}
if !session.IssuedAt.IsZero() {
ts := session.IssuedAt
item.IssuedAt = &ts
if item.AuthenticatedAt == nil {
item.AuthenticatedAt = &ts
}
if item.LastSeenAt == nil {
item.LastSeenAt = &ts
}
}
if hint, ok := auditHints[session.ID]; ok {
if item.IPAddress == "" {
item.IPAddress = hint.IPAddress
}
if item.UserAgent == "" {
item.UserAgent = hint.UserAgent
}
if item.ClientID == "" {
item.ClientID = hint.ClientID
}
if item.AppName == "" {
item.AppName = hint.AppName
}
if hint.Timestamp != nil {
item.LastSeenAt = hint.Timestamp
}
}
if item.UserAgent == "" && len(session.Devices) > 0 {
deviceUserAgent := strings.TrimSpace(session.Devices[0].UserAgent)
if !looksLikeInternalUserAgent(deviceUserAgent) {
item.UserAgent = deviceUserAgent
}
}
if item.IPAddress == "" && len(session.Devices) > 0 {
item.IPAddress = strings.TrimSpace(session.Devices[0].IPAddress)
}
items = append(items, item)
}
sort.Slice(items, func(i, j int) bool {
if items[i].IsCurrent != items[j].IsCurrent {
return items[i].IsCurrent
}
iTime := latestSessionTimestamp(items[i])
jTime := latestSessionTimestamp(items[j])
if iTime.Equal(jTime) {
return items[i].SessionID < items[j].SessionID
}
return iTime.After(jTime)
})
return c.JSON(userSessionListResponse{Items: items})
}
func (h *AuthHandler) DeleteMySession(c *fiber.Ctx) error {
if h.KratosAdmin == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "kratos admin unavailable")
}
profile, err := h.resolveCurrentProfile(c)
if err != nil {
return errorJSON(c, fiber.StatusUnauthorized, "Invalid session")
}
targetSessionID := strings.TrimSpace(c.Params("id"))
if targetSessionID == "" {
return errorJSON(c, fiber.StatusBadRequest, "session id is required")
}
mySessions, err := h.KratosAdmin.ListIdentitySessions(c.Context(), profile.ID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch sessions")
}
ownedSession := false
for _, candidate := range mySessions {
if strings.TrimSpace(candidate.ID) == targetSessionID {
ownedSession = true
break
}
}
if !ownedSession {
return errorJSON(c, fiber.StatusForbidden, "forbidden")
}
session, err := h.KratosAdmin.GetSession(c.Context(), targetSessionID)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to fetch session")
}
if session == nil {
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, "already_missing")
return c.JSON(fiber.Map{"status": "ok"})
}
result := "revoked"
if !session.Active {
result = "already_inactive"
} else if err := h.KratosAdmin.DeleteSession(c.Context(), targetSessionID); err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "Failed to delete session")
}
h.writeSessionRevokedAuditLog(c, profile.ID, h.resolveCurrentSessionID(c), targetSessionID, result)
return c.JSON(fiber.Map{"status": "ok"})
}
func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
subject, err := h.resolveConsentSubject(c)
if err != nil || subject == "" {
@@ -6751,3 +6912,179 @@ func (h *AuthHandler) ListRpHistory(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"items": items})
}
type sessionAuditHint struct {
Timestamp *time.Time
IPAddress string
UserAgent string
ClientID string
AppName string
}
func latestSessionTimestamp(item userSessionItem) time.Time {
for _, candidate := range []*time.Time{item.LastSeenAt, item.AuthenticatedAt, item.IssuedAt} {
if candidate != nil {
return *candidate
}
}
return time.Time{}
}
func (h *AuthHandler) resolveCurrentSessionID(c *fiber.Ctx) string {
if c == nil {
return ""
}
if token := h.getBearerToken(c); token != "" {
if sessionID := extractSessionIDFromJWT(token); sessionID != "" {
return sessionID
}
if sessionID, err := h.getKratosSessionID(token); err == nil {
return sessionID
}
}
if cookie := c.Get("Cookie"); cookie != "" {
if sessionID, err := h.getKratosSessionIDWithCookie(cookie); err == nil {
return sessionID
}
}
return ""
}
func (h *AuthHandler) loadSessionAuditHints(ctx context.Context, userID string) map[string]sessionAuditHint {
hints := make(map[string]sessionAuditHint)
if h.AuditRepo == nil || strings.TrimSpace(userID) == "" {
return hints
}
logs, err := h.AuditRepo.FindByUserAndEvents(ctx, userID, []string{
"login_success",
"qr_login_success",
"link_login_success",
"code_login_success",
"password_login_success",
"POST /api/v1/auth/oidc/login/accept",
"POST /api/v1/auth/password/login",
"POST /api/v1/auth/magic-link/verify",
"POST /api/v1/auth/login/code/verify",
"POST /api/v1/auth/qr/approve",
"session.revoked",
}, 200)
if err != nil {
return hints
}
for _, log := range logs {
sessionID := strings.TrimSpace(log.SessionID)
if sessionID == "" {
sessionID = strings.TrimSpace(extractApprovedSessionIDFromAuditDetails(log.Details))
}
if sessionID == "" {
sessionID = strings.TrimSpace(extractSessionIDFromAuditDetails(log.Details))
}
if sessionID == "" {
continue
}
existing, ok := hints[sessionID]
if ok && existing.Timestamp != nil && existing.Timestamp.After(log.Timestamp) {
continue
}
ts := log.Timestamp
ipAddress := strings.TrimSpace(log.IPAddress)
userAgent := strings.TrimSpace(log.UserAgent)
clientID, appName := deriveSessionClientInfo(log)
if details, err := parseAuditDetails(log.Details); err == nil {
if approvedIP, ok := details["approved_ip"].(string); ok && strings.TrimSpace(approvedIP) != "" {
ipAddress = strings.TrimSpace(approvedIP)
}
if approvedUserAgent, ok := details["approved_user_agent"].(string); ok && strings.TrimSpace(approvedUserAgent) != "" {
userAgent = strings.TrimSpace(approvedUserAgent)
}
}
if looksLikeInternalUserAgent(userAgent) {
userAgent = ""
}
hints[sessionID] = sessionAuditHint{
Timestamp: &ts,
IPAddress: ipAddress,
UserAgent: userAgent,
ClientID: clientID,
AppName: appName,
}
}
return hints
}
func deriveSessionClientInfo(log domain.AuditLog) (string, string) {
details, _ := parseAuditDetails(log.Details)
clientID := ""
appName := ""
if details != nil {
if value, ok := details["client_id"].(string); ok {
clientID = strings.TrimSpace(value)
}
if value, ok := details["client_name"].(string); ok {
appName = strings.TrimSpace(value)
}
}
path := strings.ToLower(extractAuditPath(log))
if appName == "" {
switch {
case strings.Contains(path, "/api/v1/auth/oidc/login/accept"):
appName = "OIDC 로그인"
case strings.Contains(path, "/api/v1/auth/qr/approve"):
appName = "QR 로그인"
case strings.Contains(path, "/api/v1/auth/login/code/verify"):
appName = "코드 로그인"
case strings.Contains(path, "/api/v1/auth/magic-link/verify"):
appName = "링크 로그인"
case strings.Contains(path, "/api/v1/auth/password/login"):
appName = "비밀번호 로그인"
}
}
if appName == "" && clientID != "" {
appName = clientID
}
return clientID, appName
}
func looksLikeInternalUserAgent(userAgent string) bool {
normalized := strings.ToLower(strings.TrimSpace(userAgent))
if normalized == "" {
return false
}
return strings.HasPrefix(normalized, "go-http-client/") ||
strings.HasPrefix(normalized, "fasthttp") ||
strings.HasPrefix(normalized, "fiber")
}
func (h *AuthHandler) writeSessionRevokedAuditLog(c *fiber.Ctx, actorIdentityID string, actorSessionID string, targetSessionID string, result string) {
if h.AuditRepo == nil {
return
}
details := map[string]any{
"target_session_id": strings.TrimSpace(targetSessionID),
"revoke_result": strings.TrimSpace(result),
}
if strings.TrimSpace(actorSessionID) != "" {
details["actor_session_id"] = strings.TrimSpace(actorSessionID)
}
raw, err := json.Marshal(details)
if err != nil {
return
}
_ = h.AuditRepo.Create(&domain.AuditLog{
EventID: fmt.Sprintf("session-revoked-%d", time.Now().UnixNano()),
Timestamp: time.Now().UTC(),
UserID: strings.TrimSpace(actorIdentityID),
SessionID: strings.TrimSpace(actorSessionID),
EventType: "session.revoked",
Status: "success",
IPAddress: extractClientIPFromHeaders(c),
UserAgent: strings.TrimSpace(c.Get("User-Agent")),
Details: string(raw),
})
}

View File

@@ -122,6 +122,27 @@ func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID
return nil
}
func (m *MockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
args := m.Called(ctx, identityID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]service.KratosSession), args.Error(1)
}
func (m *MockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosSession), args.Error(1)
}
func (m *MockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
args := m.Called(ctx, sessionID)
return args.Error(0)
}
// --- Helper ---
func newAuthLoginTestApp(h *AuthHandler) *fiber.App {

View File

@@ -0,0 +1,157 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestListMySessions_Success(t *testing.T) {
now := time.Date(2026, 4, 2, 1, 2, 3, 0, time.UTC)
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "current-sid",
"authenticated_at": now.Format(time.RFC3339),
"identity": map[string]any{
"id": "user-123",
"traits": map[string]any{
"email": "user@example.com",
"name": "User",
"role": "user",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
}))
mockKratos := new(MockKratosAdminService)
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
{
ID: "current-sid",
Active: true,
AuthenticatedAt: now,
ExpiresAt: now.Add(24 * time.Hour),
},
{
ID: "other-sid",
Active: true,
AuthenticatedAt: now.Add(-2 * time.Hour),
ExpiresAt: now.Add(22 * time.Hour),
},
}, nil).Once()
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
UserID: "user-123",
EventType: "login_success",
SessionID: "other-sid",
Timestamp: now.Add(-30 * time.Minute),
IPAddress: "203.0.113.10",
UserAgent: "Mozilla/5.0",
},
},
}
h := &AuthHandler{
KratosAdmin: mockKratos,
AuditRepo: auditRepo,
}
app := fiber.New()
app.Get("/api/v1/user/sessions", h.ListMySessions)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/sessions", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var body struct {
Items []struct {
SessionID string `json:"session_id"`
IsCurrent bool `json:"is_current"`
IsActive bool `json:"is_active"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
assert.NoError(t, err)
if assert.Len(t, body.Items, 2) {
assert.Equal(t, "current-sid", body.Items[0].SessionID)
assert.True(t, body.Items[0].IsCurrent)
assert.Equal(t, "other-sid", body.Items[1].SessionID)
assert.True(t, body.Items[1].IsActive)
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
assert.Equal(t, "Mozilla/5.0", body.Items[1].UserAgent)
}
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_Success(t *testing.T) {
setDefaultHTTPClientForTest(t, roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]any{
"id": "current-sid",
"authenticated_at": time.Now().UTC().Format(time.RFC3339),
"identity": map[string]any{
"id": "user-123",
"traits": map[string]any{
"email": "user@example.com",
"name": "User",
"role": "user",
},
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
}))
mockKratos := new(MockKratosAdminService)
mockKratos.On("ListIdentitySessions", mock.Anything, "user-123").Return([]service.KratosSession{
{ID: "target-sid", Active: true},
}, nil).Once()
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
ID: "target-sid",
Active: true,
}, nil).Once()
mockKratos.On("DeleteSession", mock.Anything, "target-sid").Return(nil).Once()
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
KratosAdmin: mockKratos,
AuditRepo: auditRepo,
}
app := fiber.New()
app.Delete("/api/v1/user/sessions/:id", h.DeleteMySession)
req := httptest.NewRequest(http.MethodDelete, "/api/v1/user/sessions/target-sid", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
req.Header.Set("User-Agent", "session-test-agent")
resp, err := app.Test(req, -1)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
if assert.Len(t, auditRepo.logs, 1) {
assert.Equal(t, "session.revoked", auditRepo.logs[0].EventType)
assert.Equal(t, "user-123", auditRepo.logs[0].UserID)
assert.Equal(t, "current-sid", auditRepo.logs[0].SessionID)
assert.Contains(t, auditRepo.logs[0].Details, "target-sid")
}
mockKratos.AssertExpectations(t)
}

View File

@@ -56,6 +56,26 @@ func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockKratosAdmin) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
args := m.Called(ctx, identityID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]service.KratosSession), args.Error(1)
}
func (m *MockKratosAdmin) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosSession), args.Error(1)
}
func (m *MockKratosAdmin) DeleteSession(ctx context.Context, sessionID string) error {
return m.Called(ctx, sessionID).Error(0)
}
type MockOryProvider struct {
mock.Mock
}

View File

@@ -27,6 +27,21 @@ type KratosIdentity struct {
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
type KratosSessionDevice struct {
UserAgent string `json:"user_agent,omitempty"`
IPAddress string `json:"ip_address,omitempty"`
}
type KratosSession struct {
ID string `json:"id"`
Active bool `json:"active"`
AuthenticatedAt time.Time `json:"authenticated_at,omitempty"`
ExpiresAt time.Time `json:"expires_at,omitempty"`
IssuedAt time.Time `json:"issued_at,omitempty"`
Identity *KratosIdentity `json:"identity,omitempty"`
Devices []KratosSessionDevice `json:"devices,omitempty"`
}
type KratosAdminService interface {
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
@@ -34,6 +49,9 @@ type KratosAdminService interface {
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
DeleteIdentity(ctx context.Context, identityID string) error
ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error)
GetSession(ctx context.Context, sessionID string) (*KratosSession, error)
DeleteSession(ctx context.Context, sessionID string) error
}
type kratosAdminService struct {
@@ -239,6 +257,85 @@ func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil
}
func (s *kratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
endpoint := fmt.Sprintf("%s/admin/identities/%s/sessions", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("kratos admin list identity sessions failed status=%d body=%s", resp.StatusCode, string(body))
}
var sessions []KratosSession
if err := json.NewDecoder(resp.Body).Decode(&sessions); err != nil {
return nil, err
}
return sessions, nil
}
func (s *kratosAdminService) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return nil, fmt.Errorf("kratos admin get session failed status=%d body=%s", resp.StatusCode, string(body))
}
var session KratosSession
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, err
}
return &session, nil
}
func (s *kratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
endpoint := fmt.Sprintf("%s/admin/sessions/%s", strings.TrimRight(s.AdminURL, "/"), sessionID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return err
}
resp, err := s.httpClient().Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("kratos admin delete session failed status=%d body=%s", resp.StatusCode, string(body))
}
return nil
}
func hashPasswordForKratosAdmin(password string) (string, error) {
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {

View File

@@ -110,3 +110,23 @@ func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Contex
func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error {
return m.Called(ctx, identityID).Error(0)
}
func (m *MockKratosAdminServiceShared) ListIdentitySessions(ctx context.Context, identityID string) ([]KratosSession, error) {
args := m.Called(ctx, identityID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]KratosSession), args.Error(1)
}
func (m *MockKratosAdminServiceShared) GetSession(ctx context.Context, sessionID string) (*KratosSession, error) {
args := m.Called(ctx, sessionID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*KratosSession), args.Error(1)
}
func (m *MockKratosAdminServiceShared) DeleteSession(ctx context.Context, sessionID string) error {
return m.Called(ctx, sessionID).Error(0)
}

View File

@@ -559,6 +559,20 @@ empty = "No linked apps yet."
empty_detail = "Linked apps and their latest activity will appear here."
error = "Could not load linked apps."
[msg.userfront.dashboard.sessions]
browser = "Browser: {{value}}"
empty = "No active sessions."
empty_detail = "Devices signed in with this account will appear here."
error = "Could not load sessions."
os = "OS: {{value}}"
recent_app = "Recent app: {{app}}"
session_id = "Session ID: {{id}}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "End the session for {{target}}?\nThat device will need to sign in again."
error = "Could not end the session: {{error}}"
success = "The session has been ended."
[msg.userfront.dashboard.approved_session]
copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy."
@@ -735,6 +749,7 @@ uppercase = "At least one uppercase letter"
[msg.userfront.sections]
apps_subtitle = "Your linked apps and their latest sign-in status."
audit_subtitle = "Recent access history for Baron sign-in."
sessions_subtitle = "Your currently signed-in devices and browser sessions."
[msg.userfront.settings]
disabled = "Account settings are currently unavailable."
@@ -2070,6 +2085,17 @@ status_history = "Activity history"
[ui.userfront.dashboard.activity]
linked = "Linked"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.approved_session]
default = "Default"
userfront = "Approved UserFront session ID"
@@ -2204,6 +2230,7 @@ title = "Create a new password"
[ui.userfront.sections]
apps = "Apps"
audit = "Audit"
sessions = "Sessions"
[ui.userfront.session]
active = "Active session"

View File

@@ -73,7 +73,402 @@ scope_admin = "Scoped to /admin"
session_ttl = "Session TTL: 15m admin"
tenant_headers = "Tenant-aware headers"
[msg.admin.api_keys]
[msg.admin.common]
forbidden = "이 작업을 수행할 권한이 없습니다."
[msg.admin.audit]
empty = "아직 수집된 감사 로그가 없습니다."
end = "감사 로그의 마지막입니다."
load_error = "감사 로그를 불러오지 못했습니다: {{error}}"
loading = "감사 로그를 불러오는 중..."
subtitle = "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다."
[msg.admin.header]
subtitle = "Tenant isolation & least privilege by default"
[msg.admin.notice]
idp_policy = "IDP 관리 키는 서버 내부 래핑 API로만 사용하며, 감사·레이트리밋을 기본 적용합니다."
scope = "관리 기능은 /admin 네임스페이스에서만 노출합니다."
[msg.admin.org]
hover_member_info = "마우스를 올리면 상세 정보를 확인할 수 있습니다."
import_description = "CSV 파일을 업로드하여 조직도를 일괄 등록합니다."
import_error = "조직도 임포트 중 오류가 발생했습니다."
import_success = "조직도가 성공적으로 임포트되었습니다."
[msg.admin.overview]
description = "모든 테넌트 공통 지표와 정책 상태를 한 곳에서 확인합니다."
idp_fallback = "Fallback: Descope"
idp_primary = "IDP: Ory primary"
[msg.admin.tenants]
approve_confirm = "이 테넌트를 승인하시겠습니까?"
approve_success = "테넌트가 승인되었습니다."
delete_confirm = "테넌트 \"{{name}}\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다."
missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = "테넌트 \"{{name}}\"을(를) 하위 조직에서 제외할까요?"
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.dev.auth]
access_denied_description = "DevFront는 관리자 전용 화면입니다. 권한이 필요하면 관리자에게 요청해 주세요."
access_denied_title = "접근 권한이 없습니다."
[msg.dev.forbidden]
default = "해당 리소스에 접근할 권한이 없습니다. 관리자에게 문의하세요."
rp_admin = "RP 관리자는 담당 앱의 리소스만 조회할 수 있습니다."
tenant_admin = "테넌트 관리자 권한이 올바르게 설정되지 않았거나 만료되었습니다."
user = "일반 사용자는 관리자 화면에 접근할 수 없습니다."
title = "{{resource}} 접근 권한 없음"
[msg.dev.audit]
empty = "조회된 감사 로그가 없습니다."
forbidden = "감사 로그를 조회할 권한이 없습니다. 관리자에게 권한을 요청해주세요."
load_error = "감사 로그 조회 실패: {{error}}"
loaded_count = "로드된 로그 {{count}}건"
loading = "감사 로그를 불러오는 중..."
subtitle = "현재 테넌트/앱 범위의 DevFront 작업 이력을 조회합니다."
[msg.dev.clients]
deleted = "앱이 삭제되었습니다."
delete_confirm = "정말로 이 앱을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
delete_error = "삭제 실패: {{error}}"
load_error = "앱 정보를 불러오지 못했습니다: {{error}}"
loading = "앱 정보를 불러오는 중..."
showing = "전체 {{total}}개 중 {{shown}}개를 표시하는 중입니다."
[msg.dev.sidebar]
notice = "개발자 전용 콘솔입니다."
notice_detail = "연동 앱 등록 및 관리를 수행할 수 있습니다."
[msg.dev.clients.general.public_key]
auth_method_client_secret_basic_help = "일반적인 서버 사이드 앱 인증 방식입니다."
auth_method_none_help = "PKCE 기반 public client에 사용하는 방식입니다."
auth_method_private_key_jwt_help = "Trusted RP bootstrap과 JAR 검증에 필요한 서명 키 기반 인증 방식입니다."
guide_example = "권장 예시: https://rp.example.com/.well-known/jwks.json"
guide_intro = "JWKS URI는 Baron이 만드는 값이 아니라 RP backend가 공개키를 노출하는 URL입니다."
guide_step_1 = "RP 서버에서 key pair를 생성하고 private key는 RP backend에만 보관합니다."
guide_step_2 = "RP backend가 public key를 JWKS(JSON Web Key Set) 형태로 제공하는 endpoint를 준비합니다."
guide_step_3 = "예: https://rp.example.com/.well-known/jwks.json 같은 URL을 DevFront에 입력합니다."
headless_help = "애플리케이션 고유의 디자인으로 로그인 화면을 구성할 수 있습니다. 실제 아이디/비밀번호 확인 및 보안 검증 로직은 Baron API를 통해 백그라운드에서 처리됩니다."
jwks_inline_help = "SSH-RSA 공개키 형식을 우선 권장합니다. 'ssh-rsa AAA...' 형식으로 입력하면 Baron이 OIDC 표준인 JWKS(JSON)로 자동 변환하여 저장합니다."
jwks_uri_help = "RP backend가 제공하는 공개키 endpoint URL을 입력하세요. 예: https://rp.example.com/.well-known/jwks.json"
request_object_alg_help = "Headless Login을 사용할 때 JAR(Request Object) 서명 알고리즘을 명시합니다."
source_help = "애플리케이션의 공개키(SSH-RSA)를 직접 등록하거나, 운영 환경이라면 JWKS URI를 통해 자동으로 검증할 수 있습니다."
subtitle = "Trusted RP 판정에 필요한 공개키와 headless login 관련 설정을 관리합니다."
[msg.dev.clients.general.public_key.validation]
headless_requires_alg = "Headless Login을 사용하려면 Request Object Signing Algorithm을 입력해야 합니다."
headless_requires_private_key_jwt = "Headless Login을 사용하려면 token endpoint auth method가 private_key_jwt여야 합니다."
headless_requires_public_key = "Headless Login을 사용하려면 JWKS URI가 필요합니다."
invalid_jwks_inline = "입력값이 유효한 JSON(JWKS) 형식이 아닙니다. SSH-RSA의 경우 'ssh-rsa'로 시작해야 합니다."
invalid_jwks_uri = "JWKS URI 형식이 올바르지 않습니다."
missing_jwks_inline = "공개키(SSH-RSA 또는 JWKS)를 입력해야 합니다."
missing_jwks_uri = "JWKS URI를 입력해야 합니다."
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
[msg.userfront.audit]
date = "접속일자: {{value}}"
device = "접속환경: {{value}}"
end = "더 이상 항목이 없습니다."
ip = "접속 IP: {{value}}"
load_more_error = "더 불러오지 못했습니다."
result = "인증결과: {{value}}"
session_id = "Session ID: {{value}}"
status = "현황: (준비중)"
[msg.userfront.dashboard]
approved_device = "승인 기기: {{device}}"
approved_ip = "승인 IP: {{ip}}"
audit_empty = "최근 접속 이력이 없습니다."
audit_load_error = "접속이력을 불러오지 못했습니다."
auth_method = "인증수단: {{method}}"
client_id = "Client ID: {{id}}"
client_id_missing = "Client ID 없음"
current_status = "현재 상태: {{status}}"
last_auth = "최근 인증: {{value}}"
link_missing = "이동할 페이지 주소(Client URI)가 설정되지 않았습니다."
link_open_error = "해당 링크를 열 수 없습니다."
render_error = "대시보드 렌더링 오류: {{error}}"
session_id_copied = "세션 ID가 복사되었습니다."
[msg.userfront.error]
detail_contact = "관리자에게 문의해 주세요."
detail_generic = "오류가 발생했습니다."
detail_request = "요청을 처리하는 중 문제가 발생했습니다."
id = "오류 ID: {{id}}"
title = "인증 과정에서 오류가 발생했습니다"
title_generic = "오류가 발생했습니다"
title_with_code = "오류: {{code}}"
type = "오류 종류: {{type}}"
[msg.userfront.forgot]
description = "계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다."
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
error = "전송에 실패했습니다: {{error}}"
input_required = "이메일 또는 휴대폰 번호를 입력해주세요."
sent = "비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."
[msg.userfront.login]
cookie_check_failed = "로그인 확인 실패: {{error}}"
dry_send = "drySend 모드: 실제 이메일/SMS는 발송되지 않습니다."
link_failed = "오류: {{error}}"
link_send_failed = "전송 실패: {{error}}"
link_sent_email = "입력하신 이메일로 로그인 링크를 보냈습니다."
link_sent_phone = "입력하신 번호로 로그인 링크를 보냈습니다."
link_timeout = "시간이 경과되었습니다."
no_account = "계정이 없으신가요?"
oidc_failed = "OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요."
qr_expired = "시간이 경과되었습니다."
qr_init_failed = "QR 초기화에 실패했습니다: {{error}}"
qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다"
token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {{error}}"
[msg.userfront.login_success]
subtitle = "성공적으로 로그인되었습니다."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {{error}}"
client_id = "클라이언트 ID: {{id}}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {{error}}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {{count}}개"
[msg.userfront.profile]
department_missing = "소속 정보 없음"
department_required = "소속을 입력해주세요."
email_missing = "이메일 없음"
greeting = "안녕하세요, {{name}}님"
load_failed = "정보를 불러올 수 없습니다."
name_missing = "이름 없음"
name_required = "이름을 입력해주세요."
phone_required = "휴대폰 번호를 입력해주세요."
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
update_failed = "수정 실패: {{error}}"
update_success = "정보가 수정되었습니다."
[msg.userfront.qr]
camera_error = "카메라 오류: {{error}}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다."
[msg.userfront.reset]
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
invalid_title = "유효하지 않은 링크입니다."
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.signup]
failed = "가입 실패: {{error}}"
privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[ui.admin.audit]
export_csv = "Export CSV"
load_more = "Load more"
target = "Target · {{target}}"
title = "감사 로그"
[ui.admin.groups]
import_csv = "CSV 임포트"
[ui.admin.header]
plane = "Admin Plane"
subtitle = "관리 및 정책 운영"
[ui.admin.nav]
api_keys = "API 키"
audit_logs = "감사 로그"
auth_guard = "인증 가드"
logout = "로그아웃"
overview = "개요"
relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹"
tenants = "테넌트"
users = "사용자"
[ui.admin.org]
download_template = "템플릿 다운로드"
import_btn = "임포트"
import_title = "조직도 대량 등록"
start_import = "임포트 시작"
[ui.admin.overview]
kicker = "Global Overview"
title = "통합 대시보드"
[ui.admin.profile]
manageable_tenants = "관리 가능한 테넌트"
[ui.admin.role]
rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN"
user = "TENANT MEMBER"
[ui.admin.tenants]
add = "테넌트 추가"
title = "테넌트 목록"
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[ui.dev.nav]
clients = "연동 앱"
logout = "로그아웃"
[ui.dev.tenant]
single_notice = "단일 테넌트에 소속되어 전환할 필요가 없습니다."
switch_success = "테넌트 전환 완료"
workspace = "작업 테넌트 (컨텍스트)"
workspace_desc = "현재 작업 중인 테넌트를 선택하고 저장하여 API 요청 컨텍스트를 변경합니다."
[ui.dev.audit]
load_more = "더 보기"
title = "감사 로그"
[ui.dev.profile]
menu_aria = "계정 메뉴 열기"
menu_title = "계정"
unknown_email = "unknown@example.com"
unknown_name = "Unknown User"
title = "내 정보"
subtitle = "사용자 상세 정보 및 할당된 역할(Role)을 확인합니다."
loading = "프로필 정보를 불러오는 중..."
error = "프로필 정보를 불러오지 못했습니다."
[ui.dev.clients]
new = "연동 앱 추가"
search_placeholder = "연동 앱 이름/ID로 검색..."
tenant_scoped = "Tenant-scoped"
untitled = "Untitled"
[ui.dev.dashboard]
ready_badge = "devfront ready"
[ui.dev.header]
plane = "Dev Plane"
subtitle = "Manage your applications"
[ui.dev.session]
auto_extend = "세션 만료 관리"
active = "세션 활성"
disabled = "자동 연장 비활성화"
unknown = "알 수 없음"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
refreshing = "세션 만료 시간 갱신 중..."
[ui.userfront.app_label]
admin_console = "Admin Console"
baron = "Baron 로그인"
dev_console = "Dev Console"
[ui.userfront.auth_method]
ory = "Ory 세션"
session = "세션"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
status_history = "상태 이력"
[ui.userfront.device]
android = "Mobile(Android)"
ios = "Mobile(iOS)"
linux = "Desktop(Linux)"
macos = "Desktop(macOS)"
windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
input_label = "이메일 또는 휴대폰 번호"
submit = "재설정 링크 전송"
title = "비밀번호 재설정"
[ui.userfront.login]
forgot_password = "비밀번호를 잊으셨나요?"
signup = "회원가입"
[ui.userfront.login_success]
later = "나중에 하기 (대시보드로 이동)"
qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.qr]
rescan = "다시 스캔"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[msg.admin.api_keys.create]
error = "API 키 생성에 실패했습니다."
@@ -559,6 +954,20 @@ empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.sessions]
browser = "브라우저: {{value}}"
empty = "활성 세션이 없습니다."
empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다."
error = "세션 정보를 불러오지 못했습니다."
os = "OS: {{value}}"
recent_app = "최근 접속 앱: {{app}}"
session_id = "세션 ID: {{id}}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "{{target}} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
error = "세션 종료 실패: {{error}}"
success = "세션이 종료되었습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다."
copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다."
@@ -2070,6 +2479,17 @@ status_history = "상태 이력"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "현재 접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"

View File

@@ -94,6 +94,20 @@ empty = "No linked apps yet."
empty_detail = "Linked apps and their latest activity will appear here."
error = "Could not load linked apps."
[msg.userfront.dashboard.sessions]
browser = "Browser: {value}"
empty = "No active sessions."
empty_detail = "Devices signed in with this account will appear here."
error = "Could not load sessions."
os = "OS: {value}"
recent_app = "Recent app: {app}"
session_id = "Session ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "End the session for {target}?\nThat device will need to sign in again."
error = "Could not end the session: {error}"
success = "The session has been ended."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
@@ -270,6 +284,7 @@ uppercase = "At least one uppercase letter"
[msg.userfront.sections]
apps_subtitle = "Your linked apps and their latest sign-in status."
audit_subtitle = "Recent access history for Baron sign-in."
sessions_subtitle = "Your currently signed-in devices and browser sessions."
[msg.userfront.settings]
disabled = "Account settings are currently unavailable."
@@ -450,6 +465,17 @@ status_history = "Activity history"
[ui.userfront.dashboard.activity]
linked = "Linked"
[ui.userfront.dashboard.sessions]
active_badge = "Active"
current_badge = "Current"
current_disabled = "Current session"
unknown_device = "Unknown device"
unknown_session = "Session"
[ui.userfront.dashboard.sessions.revoke]
action = "End session"
title = "End session"
[ui.userfront.dashboard.approved_session]
default = "Default"
userfront = "Approved UserFront session ID"
@@ -584,6 +610,7 @@ title = "Create a new password"
[ui.userfront.sections]
apps = "Apps"
audit = "Audit"
sessions = "Sessions"
[ui.userfront.session]
active = "Active session"

View File

@@ -171,6 +171,215 @@ qr_login_required = "로그인 한 상태여야 QR 스캔으로 로그인 할
token_missing = "로그인 토큰을 확인할 수 없습니다."
verification_failed = "승인 처리에 실패했습니다: {error}"
[msg.userfront.login_success]
subtitle = "성공적으로 로그인되었습니다."
[msg.userfront.consent]
accept_error = "동의 처리에 실패했습니다: {error}"
client_id = "클라이언트 ID: {id}"
client_unknown = "알 수 없는 앱"
description = "아래 서비스가 회원님의 계정 정보에 접근하려고 합니다.\n계속 진행하려면 동의 여부를 선택해 주세요."
load_error = "동의 정보를 불러오는데 실패했습니다: {error}"
missing_redirect = "동의가 처리되었으나 리다이렉트 URL을 받지 못했습니다."
redirect_notice = "동의 후 자동으로 서비스로 이동합니다."
scope_count = "총 {count}개"
[msg.userfront.profile]
department_missing = "소속 정보 없음"
department_required = "소속을 입력해주세요."
email_missing = "이메일 없음"
greeting = "안녕하세요, {name}님"
load_failed = "정보를 불러올 수 없습니다."
name_missing = "이름 없음"
name_required = "이름을 입력해주세요."
phone_required = "휴대폰 번호를 입력해주세요."
phone_verify_required = "휴대폰 번호 인증이 필요합니다."
update_failed = "수정 실패: {error}"
update_success = "정보가 수정되었습니다."
[msg.userfront.qr]
camera_error = "카메라 오류: {error}"
permission_error = "카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요."
permission_required = "카메라 권한이 필요합니다."
[msg.userfront.reset]
invalid_body = "비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요."
invalid_link = "유효하지 않은 재설정 링크입니다. (loginId/token 누락)"
invalid_title = "유효하지 않은 링크입니다."
policy_loading = "비밀번호 정책을 불러오는 중입니다..."
success = "비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."
[msg.userfront.sections]
apps_subtitle = "현재 연결된 앱과 최근 인증 상태입니다."
audit_subtitle = "Baron 로그인 기준의 최근 접근 기록입니다."
sessions_subtitle = "현재 로그인된 기기와 브라우저 세션입니다."
[msg.userfront.settings]
disabled = "현재 계정 설정 화면은 준비 중입니다."
[msg.userfront.signup]
failed = "가입 실패: {error}"
privacy_full = "개인정보 수집 및 이용 동의 전문..."
tos_full = "서비스 이용약관 전문..."
[ui.common.badge]
admin_only = "Admin only"
command_only = "Command only"
system = "System"
[ui.common.status]
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
[ui.userfront.app_label]
admin_console = "Admin Console"
baron = "Baron 로그인"
dev_console = "Dev Console"
[ui.userfront.auth_method]
ory = "Ory 세션"
session = "세션"
[ui.userfront.dashboard]
last_auth_label = "최근 인증"
status_history = "상태 이력"
[ui.userfront.device]
android = "Mobile(Android)"
ios = "Mobile(iOS)"
linux = "Desktop(Linux)"
macos = "Desktop(macOS)"
windows = "Desktop(Windows)"
[ui.userfront.error]
go_home = "홈으로 이동"
go_login = "로그인으로 이동"
[ui.userfront.forgot]
heading = "비밀번호를 잊으셨나요?"
input_label = "이메일 또는 휴대폰 번호"
submit = "재설정 링크 전송"
title = "비밀번호 재설정"
[ui.userfront.login]
forgot_password = "비밀번호를 잊으셨나요?"
signup = "회원가입"
[ui.userfront.login_success]
later = "나중에 하기 (대시보드로 이동)"
qr = "QR 인증 (카메라 켜기)"
title = "로그인 완료"
[ui.userfront.consent]
accept = "동의하고 계속하기"
requested_scopes = "요청된 권한"
title = "접근 권한 요청"
[ui.userfront.nav]
dashboard = "대시보드"
logout = "로그아웃"
profile = "내 정보"
qr_scan = "QR 스캔"
[ui.userfront.profile]
department_empty = "소속 정보 없음"
manage = "프로필 관리"
user_fallback = "사용자"
[ui.userfront.qr]
rescan = "다시 스캔"
result_success = "승인 완료"
title = "Scan QR Code"
[ui.userfront.reset]
confirm_password = "새 비밀번호 확인"
new_password = "새 비밀번호"
submit = "비밀번호 변경"
subtitle = "새로운 비밀번호 설정"
title = "새 비밀번호 설정"
[ui.userfront.sections]
apps = "나의 App 현황"
audit = "접속이력"
sessions = "활성 세션"
[ui.userfront.session]
active = "세션 활성"
unknown = "알 수 없음"
[ui.userfront.signup]
complete = "가입 완료"
next_step = "다음 단계"
title = "회원가입"
[msg.userfront.dashboard.activities]
empty = "연동된 앱이 없습니다."
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
error = "연동 정보를 불러오지 못했습니다."
[msg.userfront.dashboard.sessions]
browser = "브라우저: {value}"
empty = "활성 세션이 없습니다."
empty_detail = "같은 계정으로 로그인한 기기가 여기에 표시됩니다."
error = "세션 정보를 불러오지 못했습니다."
os = "OS: {value}"
recent_app = "최근 접속 앱: {app}"
session_id = "세션 ID: {id}"
[msg.userfront.dashboard.sessions.revoke]
confirm = "{target} 세션을 종료하시겠습니까?\n대상 기기에서는 다시 로그인이 필요합니다."
error = "세션 종료 실패: {error}"
success = "세션이 종료되었습니다."
[msg.userfront.dashboard.approved_session]
copy_click = "{label}: {id}\n클릭하면 복사됩니다."
copy_tap = "{label}: {id}\n탭하면 복사됩니다."
none = "{label} 없음"
[msg.userfront.dashboard.revoke]
confirm = "{app} 앱과의 연동을 해지하시겠습니까?\n해지하면 다음 로그인 시 다시 동의가 필요합니다."
error = "해지 실패: {error}"
success = "{app} 연동이 해지되었습니다."
[msg.userfront.dashboard.scopes]
empty = "요청된 권한이 없습니다."
[msg.userfront.dashboard.timeline]
load_error = "접속이력을 불러오지 못했습니다."
[msg.userfront.error.whitelist]
"$normalizedCode" = "{error}"
bad_request = "입력값을 확인해 주세요."
invalid_session = "세션이 만료되었습니다. 다시 로그인해 주세요."
not_found = "요청한 페이지를 찾을 수 없습니다."
password_or_email_mismatch = "이메일 혹은 비밀번호가 일치하지 않습니다."
rate_limited = "요청이 많습니다. 잠시 후 다시 시도해 주세요."
recovery_expired = "재설정 링크가 만료되었습니다. 다시 요청해 주세요."
recovery_invalid = "재설정 링크가 유효하지 않습니다."
settings_disabled = "현재 계정 설정 화면은 준비 중입니다."
verification_required = "추가 인증이 필요합니다. 안내에 따라 진행해 주세요."
[msg.userfront.error.ory]
"$normalizedCode" = "{error}"
access_denied = "사용자가 동의를 거부했습니다."
consent_required = "앱 접근 동의가 필요합니다."
interaction_required = "추가 상호작용이 필요합니다. 다시 시도해 주세요."
invalid_client = "클라이언트 인증 정보가 유효하지 않습니다."
invalid_grant = "인증 요청이 만료되었거나 유효하지 않습니다."
invalid_request = "잘못된 요청입니다."
invalid_scope = "요청한 권한 범위가 유효하지 않습니다."
login_required = "로그인이 필요합니다."
request_forbidden = "요청이 거부되었습니다."
server_error = "인증 서버 오류가 발생했습니다."
temporarily_unavailable = "인증 서버를 일시적으로 사용할 수 없습니다."
unauthorized_client = "해당 클라이언트는 이 요청을 수행할 수 없습니다."
unsupported_response_type = "지원하지 않는 응답 타입입니다."
[msg.userfront.login.link]
approved = "msg.userfront.login.link.approved"
helper = "입력하신 정보로 로그인 링크를 전송합니다."
@@ -450,6 +659,17 @@ status_history = "상태 이력"
[ui.userfront.dashboard.activity]
linked = "연동됨"
[ui.userfront.dashboard.sessions]
active_badge = "활성화"
current_badge = "현재 접속중"
current_disabled = "현재 세션"
unknown_device = "알 수 없는 기기"
unknown_session = "세션 정보"
[ui.userfront.dashboard.sessions.revoke]
action = "세션 종료"
title = "세션 종료"
[ui.userfront.dashboard.approved_session]
default = "승인한 세션 ID"
userfront = "승인한 Userfront 세션 ID"

View File

@@ -241,6 +241,29 @@ class AuthProxyService {
}
}
static Future<void> revokeSession(String sessionId) async {
final url = Uri.parse('$_baseUrl/api/v1/user/sessions/$sessionId');
final useCookie = AuthTokenStore.usesCookie();
final token = AuthTokenStore.getToken();
final client = createHttpClient(withCredentials: useCookie);
try {
final headers = <String, String>{'Content-Type': 'application/json'};
if (!useCookie && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
final response = await client.delete(url, headers: headers);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.revoke',
'세션 종료에 실패했습니다: {{error}}',
detail: response.body,
);
}
} finally {
client.close();
}
}
static Future<Map<String, dynamic>> verifyLoginShortCode(
String shortCode, {
bool verifyOnly = false,

View File

@@ -170,3 +170,59 @@ class RpHistoryItem {
);
}
}
class UserSessionSummary {
final String sessionId;
final DateTime? authenticatedAt;
final DateTime? expiresAt;
final DateTime? issuedAt;
final DateTime? lastSeenAt;
final String ipAddress;
final String userAgent;
final String clientId;
final String appName;
final bool isCurrent;
final bool isActive;
UserSessionSummary({
required this.sessionId,
this.authenticatedAt,
this.expiresAt,
this.issuedAt,
this.lastSeenAt,
required this.ipAddress,
required this.userAgent,
required this.clientId,
required this.appName,
required this.isCurrent,
required this.isActive,
});
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic raw) {
final value = raw?.toString();
if (value == null || value.isEmpty) {
return null;
}
try {
return DateTime.parse(value).toLocal();
} catch (_) {
return null;
}
}
return UserSessionSummary(
sessionId: json['session_id']?.toString() ?? '',
authenticatedAt: parseDate(json['authenticated_at']),
expiresAt: parseDate(json['expires_at']),
issuedAt: parseDate(json['issued_at']),
lastSeenAt: parseDate(json['last_seen_at']),
ipAddress: json['ip_address']?.toString() ?? '',
userAgent: json['user_agent']?.toString() ?? '',
clientId: json['client_id']?.toString() ?? '',
appName: json['app_name']?.toString() ?? '',
isCurrent: json['is_current'] == true,
isActive: json['is_active'] != false,
);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:convert';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/http_client.dart';
import '../models.dart';
class UserSessionsNotifier extends AsyncNotifier<List<UserSessionSummary>> {
@override
Future<List<UserSessionSummary>> build() async {
return _fetchSessions();
}
String _envOrDefault(String key, String fallback) {
if (!dotenv.isInitialized) {
return fallback;
}
return dotenv.env[key] ?? fallback;
}
Future<List<UserSessionSummary>> _fetchSessions() async {
final baseUrl = _envOrDefault('BACKEND_URL', 'https://sso.hmac.kr');
final url = Uri.parse('$baseUrl/api/v1/user/sessions');
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';
}
try {
final response = await client.get(url, headers: headers);
if (response.statusCode != 200) {
throw Exception('Failed to load sessions: ${response.statusCode}');
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
return items
.whereType<Map<String, dynamic>>()
.map(UserSessionSummary.fromJson)
.toList();
} finally {
client.close();
}
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_fetchSessions);
}
Future<void> revokeSession(String sessionId) async {
await AuthProxyService.revokeSession(sessionId);
await refresh();
}
}
final userSessionsProvider =
AsyncNotifierProvider<UserSessionsNotifier, List<UserSessionSummary>>(() {
return UserSessionsNotifier();
});

View File

@@ -9,6 +9,7 @@ import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../domain/session_time_resolver.dart';
import '../domain/providers/linked_rps_provider.dart';
import '../domain/providers/user_sessions_provider.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
@@ -45,6 +46,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
bool _auditLoading = false;
bool _auditLoadingMore = false;
bool _isRevoking = false;
String? _revokingSessionId;
bool _redirectingToSignin = false;
bool _authBootstrapInProgress = false;
@@ -130,6 +132,67 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
}
Future<void> _onRevokeSession(UserSessionSummary session) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
content: Text(
tr(
'msg.userfront.dashboard.sessions.revoke.confirm',
params: {
'target': session.isCurrent
? tr('ui.userfront.dashboard.sessions.current_badge')
: _sessionDisplayLabel(session),
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(tr('ui.common.cancel')),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(tr('ui.userfront.dashboard.sessions.revoke.action')),
),
],
),
);
if (confirmed != true) {
return;
}
setState(() => _revokingSessionId = session.sessionId);
try {
await ref.read(userSessionsProvider.notifier).revokeSession(
session.sessionId,
);
if (!mounted) {
return;
}
ToastService.success(
tr('msg.userfront.dashboard.sessions.revoke.success'),
);
} catch (e) {
if (!mounted) {
return;
}
ToastService.error(
tr(
'msg.userfront.dashboard.sessions.revoke.error',
params: {'error': '$e'},
),
);
} finally {
if (mounted) {
setState(() => _revokingSessionId = null);
}
}
}
void _onScanQR() {
context.push('/scan');
}
@@ -310,9 +373,11 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
_revokedClientIds.clear();
});
ref.invalidate(linkedRpsProvider);
ref.invalidate(userSessionsProvider);
await Future.wait([
ref.read(linkedRpsProvider.future),
ref.read(userSessionsProvider.future),
ref.read(authTimelineProvider.notifier).refresh(),
]);
@@ -758,6 +823,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
),
const SizedBox(height: 28),
],
_buildSectionTitle(
tr('ui.userfront.sections.sessions'),
tr(
'msg.userfront.sections.sessions_subtitle',
),
),
const SizedBox(height: 12),
_buildSessionSection(isMobile),
const SizedBox(height: 28),
_buildSectionTitle(
tr('ui.userfront.sections.apps'),
tr('msg.userfront.sections.apps_subtitle'),
@@ -883,6 +957,358 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
);
}
Widget _buildSessionSection(bool isMobile) {
final sessionsState = ref.watch(userSessionsProvider);
return sessionsState.when(
data: (sessions) {
if (sessions.isEmpty) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('msg.userfront.dashboard.sessions.empty'),
style: TextStyle(
fontSize: 14,
color: Colors.grey[700],
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
Text(
tr('msg.userfront.dashboard.sessions.empty_detail'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
);
}
return _buildSessionGrid(sessions, isMobile);
},
loading: () => const SizedBox(
height: 100,
child: Center(child: CircularProgressIndicator()),
),
error: (error, stack) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('msg.userfront.dashboard.sessions.error'),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 8),
TextButton(
onPressed: () => ref.read(userSessionsProvider.notifier).refresh(),
child: Text(tr('ui.common.retry')),
),
],
),
);
}
Widget _buildSessionGrid(List<UserSessionSummary> sessions, bool isMobile) {
return LayoutBuilder(
builder: (context, constraints) {
int crossAxisCount;
if (constraints.maxWidth > 1200) {
crossAxisCount = 3;
} else if (constraints.maxWidth > 800) {
crossAxisCount = 2;
} else {
crossAxisCount = 1;
}
const spacing = 12.0;
final cardWidth =
(constraints.maxWidth - (spacing * (crossAxisCount - 1))) /
crossAxisCount;
return Wrap(
spacing: spacing,
runSpacing: spacing,
children: sessions.map((session) {
return SizedBox(
width: cardWidth,
child: _buildSessionCard(session, cardWidth: cardWidth),
);
}).toList(),
);
},
);
}
Widget _buildSessionCard(UserSessionSummary session, {double? cardWidth}) {
final isCurrent = session.isCurrent;
final statusColor = session.isActive ? Colors.green : Colors.grey;
final primaryTime =
session.lastSeenAt ??
session.authenticatedAt ??
session.issuedAt ??
session.expiresAt;
final primaryTimeLabel = primaryTime != null
? _formatDateTime(primaryTime)
: tr('ui.userfront.session.unknown');
final sessionLabel = _sessionPrimaryLabel(session);
final clientLabel = _sessionClientLabel(session);
final browserLabel = _sessionBrowserLabel(session.userAgent);
final osLabel = _sessionOsLabel(session.userAgent);
final canRevoke = !isCurrent && _revokingSessionId == null;
return Container(
width: cardWidth ?? 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _surface,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isCurrent ? Colors.blueGrey : _border,
width: isCurrent ? 1.5 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 8),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
sessionLabel,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: _ink,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCurrent ? Colors.blueGrey : statusColor,
borderRadius: BorderRadius.circular(999),
),
child: Text(
isCurrent
? tr('ui.userfront.dashboard.sessions.current_badge')
: session.isActive
? tr('ui.userfront.dashboard.sessions.active_badge')
: tr('ui.common.status.inactive'),
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),
if (clientLabel.isNotEmpty) ...[
Text(
clientLabel,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: _ink,
),
),
const SizedBox(height: 8),
],
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildInfoChip(Icons.access_time, primaryTimeLabel),
if (session.ipAddress.isNotEmpty)
_buildInfoChip(Icons.public, session.ipAddress),
],
),
if (browserLabel.isNotEmpty || osLabel.isNotEmpty) ...[
const SizedBox(height: 12),
if (browserLabel.isNotEmpty)
Text(
tr(
'msg.userfront.dashboard.sessions.browser',
params: {'value': browserLabel},
),
style: TextStyle(fontSize: 13, color: Colors.grey[700]),
),
if (osLabel.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
tr(
'msg.userfront.dashboard.sessions.os',
params: {'value': osLabel},
),
style: TextStyle(fontSize: 13, color: Colors.grey[700]),
),
],
],
if (session.clientId.trim().isNotEmpty) ...[
const SizedBox(height: 6),
Text(
tr(
'msg.userfront.dashboard.client_id',
params: {'id': session.clientId},
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
],
const SizedBox(height: 8),
Text(
tr(
'msg.userfront.dashboard.sessions.session_id',
params: {'id': _compactSessionId(session.sessionId)},
),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: canRevoke ? () => _onRevokeSession(session) : null,
style: OutlinedButton.styleFrom(
foregroundColor: canRevoke ? Colors.redAccent : Colors.grey,
side: BorderSide(
color: canRevoke ? Colors.redAccent : Colors.grey,
width: 0.6,
),
padding: const EdgeInsets.symmetric(vertical: 10),
),
child: _revokingSessionId == session.sessionId
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.redAccent,
),
)
: Text(
isCurrent
? tr('ui.userfront.dashboard.sessions.current_disabled')
: tr('ui.userfront.dashboard.sessions.revoke.action'),
),
),
),
],
),
);
}
String _sessionDisplayLabel(UserSessionSummary session) {
if (session.userAgent.trim().isNotEmpty) {
return _sessionUserAgentLabel(session.userAgent);
}
return tr('ui.userfront.dashboard.sessions.unknown_device');
}
String _sessionPrimaryLabel(UserSessionSummary session) {
if (session.isCurrent) {
return tr('ui.userfront.dashboard.sessions.current_badge');
}
final appName = session.appName.trim();
if (appName.isNotEmpty) {
return appName;
}
return tr('ui.userfront.dashboard.sessions.unknown_session');
}
String _sessionClientLabel(UserSessionSummary session) {
final appName = session.appName.trim();
if (appName.isEmpty || session.isCurrent) {
return '';
}
return tr(
'msg.userfront.dashboard.sessions.recent_app',
params: {'app': appName},
);
}
String _sessionUserAgentLabel(String userAgent) {
final lower = userAgent.toLowerCase();
if (lower.isEmpty) {
return tr('ui.userfront.dashboard.sessions.unknown_device');
}
if (_looksLikeInternalUserAgent(lower)) {
return '';
}
if (lower.contains('iphone') || lower.contains('ios')) {
return tr('ui.userfront.device.ios');
}
if (lower.contains('android')) {
return tr('ui.userfront.device.android');
}
if (lower.contains('windows')) {
return tr('ui.userfront.device.windows', fallback: 'Desktop(Windows)');
}
if (lower.contains('mac os') || lower.contains('macintosh')) {
return tr('ui.userfront.device.macos', fallback: 'Desktop(macOS)');
}
if (lower.contains('linux')) {
return tr('ui.userfront.device.linux');
}
return userAgent;
}
String _sessionBrowserLabel(String userAgent) {
final lower = userAgent.toLowerCase();
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
return '';
}
if (lower.contains('edg/')) {
return 'Edge';
}
if (lower.contains('chrome/') && !lower.contains('edg/')) {
return 'Chrome';
}
if (lower.contains('firefox/')) {
return 'Firefox';
}
if (lower.contains('safari/') && !lower.contains('chrome/')) {
return 'Safari';
}
if (lower.contains('samsungbrowser/')) {
return 'Samsung Internet';
}
if (lower.contains('flutter')) {
return 'Flutter';
}
return '';
}
String _sessionOsLabel(String userAgent) {
final lower = userAgent.toLowerCase();
if (lower.isEmpty || _looksLikeInternalUserAgent(lower)) {
return '';
}
if (lower.contains('iphone') || lower.contains('ios')) {
return 'iOS';
}
if (lower.contains('android')) {
return 'Android';
}
if (lower.contains('windows')) {
return 'Windows';
}
if (lower.contains('mac os') || lower.contains('macintosh')) {
return 'macOS';
}
if (lower.contains('linux')) {
return 'Linux';
}
return '';
}
bool _looksLikeInternalUserAgent(String userAgent) {
return userAgent.startsWith('go-http-client/') ||
userAgent.startsWith('fasthttp') ||
userAgent.startsWith('fiber');
}
Widget _buildInfoChip(IconData icon, String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),