forked from baron/baron-sso
사용자 활성 세션 조회·종료 API 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
157
backend/internal/handler/auth_handler_sessions_test.go
Normal file
157
backend/internal/handler/auth_handler_sessions_test.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
422
locales/ko.toml
422
locales/ko.toml
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user