forked from baron/baron-sso
Merge pull request 'feature/uf-session' (#532) from feature/uf-session into dev
Reviewed-on: baron/baron-sso#532
This commit is contained in:
@@ -4246,11 +4246,10 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
consent, ok := consentMap[clientID]
|
consent, ok := consentMap[clientID]
|
||||||
if !ok {
|
if ok {
|
||||||
continue
|
if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
|
||||||
}
|
continue
|
||||||
if !consent.ConsentAt.IsZero() && log.Timestamp.Before(consent.ConsentAt) {
|
}
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
oathkeeperLogs = append(oathkeeperLogs, log)
|
oathkeeperLogs = append(oathkeeperLogs, log)
|
||||||
if len(oathkeeperLogs) >= fetchLimit {
|
if len(oathkeeperLogs) >= fetchLimit {
|
||||||
@@ -4299,36 +4298,75 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
return info, true
|
return info, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clientCache := make(map[string]loginClientInfo)
|
||||||
|
resolveClientByID := func(cid string) (loginClientInfo, bool) {
|
||||||
|
cid = strings.TrimSpace(cid)
|
||||||
|
if cid == "" || h.Hydra == nil {
|
||||||
|
return loginClientInfo{}, false
|
||||||
|
}
|
||||||
|
if cached, ok := clientCache[cid]; ok {
|
||||||
|
return cached, cached.ClientID != ""
|
||||||
|
}
|
||||||
|
client, err := h.Hydra.GetClient(c.Context(), cid)
|
||||||
|
if err != nil || client == nil {
|
||||||
|
clientCache[cid] = loginClientInfo{}
|
||||||
|
return loginClientInfo{}, false
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(client.ClientName)
|
||||||
|
if name == "" {
|
||||||
|
name = cid
|
||||||
|
}
|
||||||
|
info := loginClientInfo{
|
||||||
|
ClientID: cid,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
clientCache[cid] = info
|
||||||
|
return info, true
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
|
items := make([]authTimelineItem, 0, len(authLogs)+len(oathkeeperLogs))
|
||||||
for i := range authLogs {
|
for i := range authLogs {
|
||||||
log := authLogs[i]
|
log := authLogs[i]
|
||||||
appName := "Baron 로그인"
|
appName := "Baron 로그인"
|
||||||
clientID := ""
|
clientID := ""
|
||||||
path := strings.ToLower(extractAuditPath(log))
|
path := strings.ToLower(extractAuditPath(log))
|
||||||
if strings.Contains(path, "/api/v1/auth/oidc/login/accept") {
|
|
||||||
appName = "OIDC 로그인"
|
isOidcAccept := strings.Contains(path, "/api/v1/auth/oidc/login/accept")
|
||||||
// 우선 audit details의 client 정보를 사용하고, 없으면 Hydra 조회로 보강
|
isPasswordLogin := strings.Contains(path, "/api/v1/auth/password/login")
|
||||||
if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
|
|
||||||
if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
|
// 우선 audit details의 client 정보를 사용
|
||||||
appName = strings.TrimSpace(name)
|
if details, err := utils.ParseAuditDetails(log.Details); err == nil && details != nil {
|
||||||
}
|
if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" {
|
||||||
if cid, ok := details["client_id"].(string); ok && strings.TrimSpace(cid) != "" {
|
clientID = strings.TrimSpace(cid)
|
||||||
clientID = strings.TrimSpace(cid)
|
|
||||||
if appName == "OIDC 로그인" {
|
|
||||||
appName = clientID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if appName == "OIDC 로그인" {
|
if name, ok := details["client_name"].(string); ok && strings.TrimSpace(name) != "" {
|
||||||
loginChallenge := extractLoginChallengeFromAuditDetails(log.Details)
|
appName = strings.TrimSpace(name)
|
||||||
if loginChallenge != "" {
|
}
|
||||||
if info, ok := resolveLoginClient(loginChallenge); ok {
|
}
|
||||||
appName = info.Name
|
|
||||||
clientID = info.ClientID
|
// 기본값이거나 클라이언트 ID인 경우 Hydra 조회로 보강
|
||||||
}
|
if appName == "Baron 로그인" || appName == "" {
|
||||||
|
if isOidcAccept {
|
||||||
|
appName = "OIDC 로그인"
|
||||||
|
}
|
||||||
|
if clientID != "" {
|
||||||
|
appName = clientID
|
||||||
|
if info, ok := resolveClientByID(clientID); ok {
|
||||||
|
appName = info.Name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isOidcAccept || isPasswordLogin) && (appName == "OIDC 로그인" || appName == "Baron 로그인" || appName == clientID) {
|
||||||
|
loginChallenge := extractLoginChallengeFromAuditDetails(log.Details)
|
||||||
|
if loginChallenge != "" {
|
||||||
|
if info, ok := resolveLoginClient(loginChallenge); ok {
|
||||||
|
appName = info.Name
|
||||||
|
clientID = info.ClientID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
item := authTimelineItem{
|
item := authTimelineItem{
|
||||||
EventID: log.EventID,
|
EventID: log.EventID,
|
||||||
Timestamp: log.Timestamp,
|
Timestamp: log.Timestamp,
|
||||||
@@ -4353,11 +4391,17 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
if clientID == "" {
|
if clientID == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
consent := consentMap[clientID]
|
|
||||||
appName := consent.Name
|
appName := clientID
|
||||||
if appName == "" {
|
if consent, ok := consentMap[clientID]; ok {
|
||||||
appName = clientID
|
appName = consent.Name
|
||||||
}
|
}
|
||||||
|
if appName == "" || appName == clientID {
|
||||||
|
if info, ok := resolveClientByID(clientID); ok {
|
||||||
|
appName = info.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
details := map[string]any{
|
details := map[string]any{
|
||||||
"path": log.Path,
|
"path": log.Path,
|
||||||
"client_id": clientID,
|
"client_id": clientID,
|
||||||
@@ -4374,6 +4418,7 @@ func (h *AuthHandler) GetAuthTimeline(c *fiber.Ctx) error {
|
|||||||
EventID: eventID,
|
EventID: eventID,
|
||||||
Timestamp: log.Timestamp,
|
Timestamp: log.Timestamp,
|
||||||
UserID: profile.ID,
|
UserID: profile.ID,
|
||||||
|
SessionID: extractSessionIDFromOathkeeperLog(log),
|
||||||
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
EventType: fmt.Sprintf("%s %s", log.Method, log.Path),
|
||||||
Status: status,
|
Status: status,
|
||||||
AuthMethod: "세션 위임",
|
AuthMethod: "세션 위임",
|
||||||
@@ -5697,6 +5742,16 @@ func extractClientIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
|||||||
return parseClientIDFromRaw(log.Raw)
|
return parseClientIDFromRaw(log.Raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractSessionIDFromOathkeeperLog(log domain.OathkeeperAccessLog) string {
|
||||||
|
if value := parseSessionIDFromURL(log.Target); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
if value := parseSessionIDFromURL(log.Path); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return parseSessionIDFromRaw(log.Raw)
|
||||||
|
}
|
||||||
|
|
||||||
func parseClientIDFromURL(raw string) string {
|
func parseClientIDFromURL(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -5715,6 +5770,23 @@ func parseClientIDFromURL(raw string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSessionIDFromURL(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
|
||||||
|
if id := strings.TrimSpace(parsed.Query().Get(key)); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func parseClientIDFromRaw(raw string) string {
|
func parseClientIDFromRaw(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -5766,15 +5838,7 @@ func extractSessionIDFromAuditDetails(details string) string {
|
|||||||
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
if err := json.Unmarshal([]byte(details), &payload); err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
if raw, ok := payload["session_id"]; ok {
|
return readSessionIDFromAny(payload)
|
||||||
switch value := raw.(type) {
|
|
||||||
case string:
|
|
||||||
return value
|
|
||||||
default:
|
|
||||||
return fmt.Sprint(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractApprovedSessionIDFromAuditDetails(details string) string {
|
func extractApprovedSessionIDFromAuditDetails(details string) string {
|
||||||
@@ -5804,6 +5868,51 @@ func extractApprovedSessionIDFromAuditDetails(details string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSessionIDFromRaw(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var payload any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return readSessionIDFromAny(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readSessionIDFromAny(payload any) string {
|
||||||
|
switch value := payload.(type) {
|
||||||
|
case map[string]any:
|
||||||
|
for _, key := range []string{"session_id", "sid", "sessionId", "sessionID"} {
|
||||||
|
if raw, ok := value[key]; ok {
|
||||||
|
switch sid := raw.(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(sid) != "" {
|
||||||
|
return strings.TrimSpace(sid)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
rendered := strings.TrimSpace(fmt.Sprint(sid))
|
||||||
|
if rendered != "" && rendered != "<nil>" {
|
||||||
|
return rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, nested := range value {
|
||||||
|
if sid := readSessionIDFromAny(nested); sid != "" {
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, nested := range value {
|
||||||
|
if sid := readSessionIDFromAny(nested); sid != "" {
|
||||||
|
return sid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
func (h *AuthHandler) resolveIdentityID(c *fiber.Ctx, token string) (string, error) {
|
||||||
id, _, _, err := h.getKratosIdentity(token)
|
id, _, _, err := h.getKratosIdentity(token)
|
||||||
return id, err
|
return id, err
|
||||||
|
|||||||
@@ -616,3 +616,70 @@ func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) {
|
|||||||
assert.Contains(t, err.Error(), "inactive")
|
assert.Contains(t, err.Error(), "inactive")
|
||||||
mockKratos.AssertExpectations(t)
|
mockKratos.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAuthTimeline_FillsSessionIDFromOathkeeperRaw(t *testing.T) {
|
||||||
|
now := time.Date(2026, 4, 7, 4, 39, 0, 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
|
||||||
|
}))
|
||||||
|
|
||||||
|
h := &AuthHandler{
|
||||||
|
AuditRepo: &mockAuditRepo{},
|
||||||
|
OathkeeperRepo: &mockOathkeeperRepo{
|
||||||
|
logs: []domain.OathkeeperAccessLog{
|
||||||
|
{
|
||||||
|
Timestamp: now,
|
||||||
|
RequestID: "req-1",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
Path: "/api/v1/dev/sessions",
|
||||||
|
Status: http.StatusOK,
|
||||||
|
Subject: "user-123",
|
||||||
|
ClientIP: "203.0.113.7",
|
||||||
|
UserAgent: "Mozilla/5.0",
|
||||||
|
Raw: `{"request":{"url":"https://devfront.example.com/callback?client_id=devfront"},"extra":{"session_id":"target-sid"}}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
app := fiber.New()
|
||||||
|
app.Get("/api/v1/audit/auth/timeline", h.GetAuthTimeline)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/audit/auth/timeline", 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"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
AppName string `json:"app_name"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if assert.Len(t, body.Items, 1) {
|
||||||
|
assert.Equal(t, "target-sid", body.Items[0].SessionID)
|
||||||
|
assert.Equal(t, "devfront", body.Items[0].ClientID)
|
||||||
|
assert.Equal(t, "devfront", body.Items[0].AppName)
|
||||||
|
assert.Equal(t, "oathkeeper", body.Items[0].Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,25 @@ func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time
|
|||||||
|
|
||||||
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
type mockOathkeeperRepo struct {
|
||||||
|
logs []domain.OathkeeperAccessLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
|
||||||
|
if subject == "" {
|
||||||
|
return m.logs, nil
|
||||||
|
}
|
||||||
|
results := make([]domain.OathkeeperAccessLog, 0, len(m.logs))
|
||||||
|
for _, log := range m.logs {
|
||||||
|
if log.Subject == subject {
|
||||||
|
results = append(results, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
// --- Mock Consent Repository ---
|
// --- Mock Consent Repository ---
|
||||||
|
|
||||||
type mockConsentRepo struct {
|
type mockConsentRepo struct {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -44,7 +44,7 @@ function ClientDetailsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientId = params.id ?? "";
|
const clientId = params.id ?? "";
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, error } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId),
|
queryFn: () => fetchClient(clientId),
|
||||||
enabled: clientId.length > 0,
|
enabled: clientId.length > 0,
|
||||||
@@ -52,12 +52,18 @@ function ClientDetailsPage() {
|
|||||||
|
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
const [showSecret, setShowSecret] = useState(false);
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
const redirectUrisHydratedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.client?.redirectUris) {
|
if (
|
||||||
|
!redirectUrisHydratedRef.current &&
|
||||||
|
data?.client?.redirectUris &&
|
||||||
|
redirectUris === ""
|
||||||
|
) {
|
||||||
setRedirectUris(data.client.redirectUris.join(", "));
|
setRedirectUris(data.client.redirectUris.join(", "));
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data, redirectUris]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -129,15 +135,7 @@ function ClientDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (error && !data) {
|
||||||
return (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
{t("msg.dev.clients.details.loading", "Loading app...")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
const errMsg =
|
const errMsg =
|
||||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
(error as Error)?.message;
|
(error as Error)?.message;
|
||||||
@@ -152,37 +150,45 @@ function ClientDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const client = data?.client;
|
||||||
|
const endpointValues = data?.endpoints ?? {
|
||||||
|
discovery: "-",
|
||||||
|
issuer: "-",
|
||||||
|
authorization: "-",
|
||||||
|
token: "-",
|
||||||
|
userinfo: "-",
|
||||||
|
};
|
||||||
const endpoints = [
|
const endpoints = [
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||||
labelFallback: "Discovery Endpoint",
|
labelFallback: "Discovery Endpoint",
|
||||||
value: data.endpoints.discovery,
|
value: endpointValues.discovery,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
||||||
labelFallback: "Issuer URL",
|
labelFallback: "Issuer URL",
|
||||||
value: data.endpoints.issuer,
|
value: endpointValues.issuer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
||||||
labelFallback: "Authorization Endpoint",
|
labelFallback: "Authorization Endpoint",
|
||||||
value: data.endpoints.authorization,
|
value: endpointValues.authorization,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.token",
|
labelKey: "ui.dev.clients.details.endpoint.token",
|
||||||
labelFallback: "Token Endpoint",
|
labelFallback: "Token Endpoint",
|
||||||
value: data.endpoints.token,
|
value: endpointValues.token,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
||||||
labelFallback: "UserInfo Endpoint",
|
labelFallback: "UserInfo Endpoint",
|
||||||
value: data.endpoints.userinfo,
|
value: endpointValues.userinfo,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Client Secret from API
|
// Client Secret from API
|
||||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||||
const clientSecret = data.client.clientSecret || secretPlaceholder;
|
const clientSecret = client?.clientSecret || secretPlaceholder;
|
||||||
const displaySecret =
|
const displaySecret =
|
||||||
clientSecret === secretPlaceholder
|
clientSecret === secretPlaceholder
|
||||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||||
@@ -200,7 +206,7 @@ function ClientDetailsPage() {
|
|||||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{data.client.name || clientId}</span>
|
<span>{client?.name || clientId}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||||
@@ -215,7 +221,7 @@ function ClientDetailsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
||||||
{data.client.name || data.client.id}
|
{client?.name || client?.id || clientId}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -226,12 +232,14 @@ function ClientDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={data.client.status === "active" ? "info" : "muted"}
|
variant={client?.status === "active" ? "info" : "muted"}
|
||||||
className="px-3 py-1 text-xs uppercase"
|
className="px-3 py-1 text-xs uppercase"
|
||||||
>
|
>
|
||||||
{data.client.status === "active"
|
{client?.status === "active"
|
||||||
? t("ui.common.status.active", "Active")
|
? t("ui.common.status.active", "Active")
|
||||||
: t("ui.common.status.inactive", "Inactive")}
|
: client?.status === "inactive"
|
||||||
|
? t("ui.common.status.inactive", "Inactive")
|
||||||
|
: t("msg.common.loading", "Loading...")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 border-b border-border">
|
<div className="flex gap-6 border-b border-border">
|
||||||
@@ -276,10 +284,10 @@ function ClientDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-mono text-lg truncate">
|
<p className="font-mono text-lg truncate">
|
||||||
{data.client.id}
|
{client?.id || clientId}
|
||||||
</p>
|
</p>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
value={data.client.id}
|
value={client?.id || clientId}
|
||||||
onCopy={() =>
|
onCopy={() =>
|
||||||
toast(
|
toast(
|
||||||
t(
|
t(
|
||||||
@@ -461,7 +469,10 @@ function ClientDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
rows={5}
|
rows={5}
|
||||||
value={redirectUris}
|
value={redirectUris}
|
||||||
onChange={(e) => setRedirectUris(e.target.value)}
|
onChange={(e) => {
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
|
setRedirectUris(e.target.value);
|
||||||
|
}}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -509,9 +509,11 @@ saved_success = "Saved successfully."
|
|||||||
greeting = "Hello, {{name}}."
|
greeting = "Hello, {{name}}."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "Browser: {{value}}"
|
||||||
date = "Date: {{value}}"
|
date = "Date: {{value}}"
|
||||||
device = "Device: {{value}}"
|
device = "Device: {{value}}"
|
||||||
end = "No more items to show."
|
end = "No more items to show."
|
||||||
|
filtered_empty = "No sign-in history matches the active session filter."
|
||||||
ip = "IP address: {{value}}"
|
ip = "IP address: {{value}}"
|
||||||
load_more_error = "Could not load more history."
|
load_more_error = "Could not load more history."
|
||||||
result = "Result: {{value}}"
|
result = "Result: {{value}}"
|
||||||
@@ -2055,8 +2057,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "Action"
|
||||||
app = "App"
|
app = "App"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
|
browser = "Browser"
|
||||||
date = "Date"
|
date = "Date"
|
||||||
device = "Device"
|
device = "Device"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -2278,3 +2282,11 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
action = "Go to sign-in"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "Manage My Activity"
|
||||||
|
toggle_label = "Show active sessions only"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "Toggle to view only active sessions."
|
||||||
|
|||||||
@@ -171,9 +171,11 @@ missing_jwks_uri = "JWKS URI를 입력해야 합니다."
|
|||||||
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
|
private_key_jwt_requires_public_key = "서명 키 기반 인증을 사용하려면 JWKS URI가 필요합니다."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {{value}}"
|
||||||
date = "접속일자: {{value}}"
|
date = "접속일자: {{value}}"
|
||||||
device = "접속환경: {{value}}"
|
device = "접속환경: {{value}}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {{value}}"
|
ip = "접속 IP: {{value}}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {{value}}"
|
result = "인증결과: {{value}}"
|
||||||
@@ -904,9 +906,11 @@ saved_success = "저장이 완료되었습니다."
|
|||||||
greeting = "안녕하세요, {{name}}님"
|
greeting = "안녕하세요, {{name}}님"
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {{value}}"
|
||||||
date = "접속일자: {{value}}"
|
date = "접속일자: {{value}}"
|
||||||
device = "접속환경: {{value}}"
|
device = "접속환경: {{value}}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {{value}}"
|
ip = "접속 IP: {{value}}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {{value}}"
|
result = "인증결과: {{value}}"
|
||||||
@@ -2449,8 +2453,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "관리"
|
||||||
app = "애플리케이션"
|
app = "애플리케이션"
|
||||||
auth_method = "인증수단"
|
auth_method = "인증수단"
|
||||||
|
browser = "브라우저"
|
||||||
date = "접속일자"
|
date = "접속일자"
|
||||||
device = "접속환경"
|
device = "접속환경"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -2481,7 +2487,7 @@ linked = "연동됨"
|
|||||||
|
|
||||||
[ui.userfront.dashboard.sessions]
|
[ui.userfront.dashboard.sessions]
|
||||||
active_badge = "활성화"
|
active_badge = "활성화"
|
||||||
current_badge = "현재 접속중"
|
current_badge = "접속중"
|
||||||
current_disabled = "현재 세션"
|
current_disabled = "현재 세션"
|
||||||
unknown_device = "알 수 없는 기기"
|
unknown_device = "알 수 없는 기기"
|
||||||
unknown_session = "세션 정보"
|
unknown_session = "세션 정보"
|
||||||
@@ -2671,3 +2677,11 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "내 활동 관리"
|
||||||
|
toggle_label = "활성 세션만 보기"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|||||||
@@ -782,9 +782,11 @@ saved_success = ""
|
|||||||
greeting = ""
|
greeting = ""
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
end = ""
|
end = ""
|
||||||
|
filtered_empty = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
load_more_error = ""
|
load_more_error = ""
|
||||||
result = ""
|
result = ""
|
||||||
@@ -2327,8 +2329,10 @@ dev_console = ""
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = ""
|
||||||
app = ""
|
app = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
@@ -2549,3 +2553,11 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = ""
|
||||||
|
toggle_label = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = ""
|
||||||
|
|||||||
@@ -44,9 +44,11 @@ missing = "No active session was found."
|
|||||||
greeting = "Hello, {name}."
|
greeting = "Hello, {name}."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "Browser: {value}"
|
||||||
date = "Date: {value}"
|
date = "Date: {value}"
|
||||||
device = "Device: {value}"
|
device = "Device: {value}"
|
||||||
end = "No more items to show."
|
end = "No more items to show."
|
||||||
|
filtered_empty = "No sign-in history matches the active session filter."
|
||||||
ip = "IP address: {value}"
|
ip = "IP address: {value}"
|
||||||
load_more_error = "Could not load more history."
|
load_more_error = "Could not load more history."
|
||||||
result = "Result: {value}"
|
result = "Result: {value}"
|
||||||
@@ -435,8 +437,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "Action"
|
||||||
app = "App"
|
app = "App"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
|
browser = "Browser"
|
||||||
date = "Date"
|
date = "Date"
|
||||||
device = "Device"
|
device = "Device"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -658,3 +662,11 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
action = "Go to sign-in"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "Manage My Activity"
|
||||||
|
toggle_label = "Show active sessions only"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "Toggle to view only active sessions."
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ verify_code_failed = "인증 실패: {error}"
|
|||||||
missing = "활성 세션이 없습니다."
|
missing = "활성 세션이 없습니다."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {value}"
|
||||||
date = "접속일자: {value}"
|
date = "접속일자: {value}"
|
||||||
device = "접속환경: {value}"
|
device = "접속환경: {value}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {value}"
|
ip = "접속 IP: {value}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {value}"
|
result = "인증결과: {value}"
|
||||||
@@ -248,9 +250,11 @@ title = "회원가입"
|
|||||||
greeting = "안녕하세요, {name}님"
|
greeting = "안녕하세요, {name}님"
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {value}"
|
||||||
date = "접속일자: {value}"
|
date = "접속일자: {value}"
|
||||||
device = "접속환경: {value}"
|
device = "접속환경: {value}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {value}"
|
ip = "접속 IP: {value}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {value}"
|
result = "인증결과: {value}"
|
||||||
@@ -638,8 +642,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "관리"
|
||||||
app = "애플리케이션"
|
app = "애플리케이션"
|
||||||
auth_method = "인증수단"
|
auth_method = "인증수단"
|
||||||
|
browser = "브라우저"
|
||||||
date = "접속일자"
|
date = "접속일자"
|
||||||
device = "접속환경"
|
device = "접속환경"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -670,7 +676,7 @@ linked = "연동됨"
|
|||||||
|
|
||||||
[ui.userfront.dashboard.sessions]
|
[ui.userfront.dashboard.sessions]
|
||||||
active_badge = "활성화"
|
active_badge = "활성화"
|
||||||
current_badge = "현재 접속중"
|
current_badge = "접속중"
|
||||||
current_disabled = "현재 세션"
|
current_disabled = "현재 세션"
|
||||||
unknown_device = "알 수 없는 기기"
|
unknown_device = "알 수 없는 기기"
|
||||||
unknown_session = "세션 정보"
|
unknown_session = "세션 정보"
|
||||||
@@ -860,3 +866,11 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "내 활동 관리"
|
||||||
|
toggle_label = "활성 세션만 보기"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|||||||
@@ -223,9 +223,11 @@ title = ""
|
|||||||
greeting = ""
|
greeting = ""
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
end = ""
|
end = ""
|
||||||
|
filtered_empty = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
load_more_error = ""
|
load_more_error = ""
|
||||||
result = ""
|
result = ""
|
||||||
@@ -613,8 +615,10 @@ dev_console = ""
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = ""
|
||||||
app = ""
|
app = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
@@ -835,3 +839,11 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = ""
|
||||||
|
toggle_label = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = ""
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
static const double _historySessionMinWidth = 92;
|
static const double _historySessionMinWidth = 92;
|
||||||
static const double _historyOtherColumnsBaselineWidth = 780;
|
static const double _historyOtherColumnsBaselineWidth = 780;
|
||||||
static const int _historySessionMinVisibleChars = 8;
|
static const int _historySessionMinVisibleChars = 8;
|
||||||
|
static const double _historyDateColumnWidth = 132;
|
||||||
|
static const double _historyAppColumnWidth = 132;
|
||||||
|
static const double _historyIpColumnWidth = 118;
|
||||||
|
static const double _historyDeviceColumnWidth = 128;
|
||||||
|
static const double _historyBrowserColumnWidth = 112;
|
||||||
|
static const double _historyAuthMethodColumnWidth = 108;
|
||||||
|
static const double _historyResultColumnWidth = 88;
|
||||||
|
static const double _historyStatusColumnWidth = 92;
|
||||||
|
static const double _historyActionColumnWidth = 108;
|
||||||
|
|
||||||
final ScrollController _pageScrollController = ScrollController();
|
final ScrollController _pageScrollController = ScrollController();
|
||||||
final ScrollController _rpScrollController = ScrollController();
|
final ScrollController _rpScrollController = ScrollController();
|
||||||
@@ -53,6 +62,7 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
bool _authBootstrapInProgress = false;
|
bool _authBootstrapInProgress = false;
|
||||||
|
|
||||||
bool _showAllActivities = false;
|
bool _showAllActivities = false;
|
||||||
|
bool _showActiveSessionsOnly = false;
|
||||||
final Set<String> _revokedClientIds = {};
|
final Set<String> _revokedClientIds = {};
|
||||||
|
|
||||||
String _renderTranslatedText(
|
String _renderTranslatedText(
|
||||||
@@ -151,9 +161,9 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
|
title: Text(tr('ui.userfront.dashboard.sessions.revoke.title')),
|
||||||
content: Text(
|
content: Text(
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.dashboard.sessions.revoke.confirm',
|
'msg.userfront.dashboard.sessions.revoke.confirm',
|
||||||
params: {
|
values: {
|
||||||
'target': session.isCurrent
|
'target': session.isCurrent
|
||||||
? tr('ui.userfront.dashboard.sessions.current_badge')
|
? tr('ui.userfront.dashboard.sessions.current_badge')
|
||||||
: _sessionDisplayLabel(session),
|
: _sessionDisplayLabel(session),
|
||||||
@@ -496,6 +506,17 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
return SelectableText(text, style: style);
|
return SelectableText(text, style: style);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _singleLineText(String text, {TextStyle? style}) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: style,
|
||||||
|
maxLines: 1,
|
||||||
|
softWrap: false,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _authMethodLabel() {
|
String _authMethodLabel() {
|
||||||
if (AuthTokenStore.usesCookie()) {
|
if (AuthTokenStore.usesCookie()) {
|
||||||
return tr('ui.userfront.auth_method.ory');
|
return tr('ui.userfront.auth_method.ory');
|
||||||
@@ -836,13 +857,6 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
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(
|
_buildSectionTitle(
|
||||||
tr('ui.userfront.sections.apps'),
|
tr('ui.userfront.sections.apps'),
|
||||||
tr('msg.userfront.sections.apps_subtitle'),
|
tr('msg.userfront.sections.apps_subtitle'),
|
||||||
@@ -972,245 +986,6 @@ 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) {
|
|
||||||
final crossAxisCount = _dashboardCardColumnCount(constraints.maxWidth);
|
|
||||||
final cardWidth = _dashboardCardWidth(
|
|
||||||
constraints.maxWidth,
|
|
||||||
crossAxisCount,
|
|
||||||
);
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: _dashboardCardSpacing,
|
|
||||||
runSpacing: _dashboardCardSpacing,
|
|
||||||
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(
|
|
||||||
_renderTranslatedText(
|
|
||||||
'msg.userfront.dashboard.sessions.browser',
|
|
||||||
values: {'value': browserLabel},
|
|
||||||
),
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
if (osLabel.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
_renderTranslatedText(
|
|
||||||
'msg.userfront.dashboard.sessions.os',
|
|
||||||
values: {'value': osLabel},
|
|
||||||
),
|
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
if (session.clientId.trim().isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
_renderTranslatedText(
|
|
||||||
'msg.userfront.dashboard.client_id',
|
|
||||||
fallback: 'Client ID: {{id}}',
|
|
||||||
values: {'id': session.clientId},
|
|
||||||
),
|
|
||||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_renderTranslatedText(
|
|
||||||
'msg.userfront.dashboard.sessions.session_id',
|
|
||||||
fallback: 'Session ID: {{id}}',
|
|
||||||
values: {'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) {
|
String _sessionDisplayLabel(UserSessionSummary session) {
|
||||||
if (session.userAgent.trim().isNotEmpty) {
|
if (session.userAgent.trim().isNotEmpty) {
|
||||||
return _sessionUserAgentLabel(session.userAgent);
|
return _sessionUserAgentLabel(session.userAgent);
|
||||||
@@ -1709,46 +1484,167 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
|
Widget _buildAccessHistory(AuthTimelineState state, bool isWide) {
|
||||||
|
final sessionsState = ref.watch(userSessionsProvider);
|
||||||
if (state.isLoading && state.items.isEmpty) {
|
if (state.isLoading && state.items.isEmpty) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
child: const SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.error != null && state.items.isEmpty) {
|
if (state.error != null && state.items.isEmpty) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: Center(
|
child: SizedBox(
|
||||||
child: Column(
|
height: 120,
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Center(
|
||||||
children: [
|
child: Column(
|
||||||
Text(tr('msg.userfront.dashboard.audit_load_error')),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
TextButton(
|
Text(tr('msg.userfront.dashboard.audit_load_error')),
|
||||||
onPressed: () =>
|
const SizedBox(height: 8),
|
||||||
ref.read(authTimelineProvider.notifier).refresh(),
|
TextButton(
|
||||||
child: Text(tr('ui.common.retry')),
|
onPressed: () =>
|
||||||
),
|
ref.read(authTimelineProvider.notifier).refresh(),
|
||||||
],
|
child: Text(tr('ui.common.retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.items.isEmpty) {
|
if (sessionsState.isLoading && !sessionsState.hasValue) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: Center(
|
child: const SizedBox(
|
||||||
child: Text(
|
height: 120,
|
||||||
tr('msg.userfront.dashboard.audit_empty'),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionsState.hasError && !sessionsState.hasValue) {
|
||||||
|
return _buildHistoryContainer(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 120,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(tr('msg.userfront.dashboard.sessions.error')),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(userSessionsProvider.notifier).refresh(),
|
||||||
|
child: Text(tr('ui.common.retry')),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final sessions = sessionsState is AsyncData<List<UserSessionSummary>>
|
||||||
|
? sessionsState.value
|
||||||
|
: const <UserSessionSummary>[];
|
||||||
|
final Map<String, UserSessionSummary> sessionById = {
|
||||||
|
for (final session in sessions) session.sessionId.trim(): session,
|
||||||
|
};
|
||||||
|
final filteredItems = state.items.where((log) {
|
||||||
|
if (!_showActiveSessionsOnly) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
final status = _historySessionStatusForLog(log, sessionById);
|
||||||
|
return status != _HistorySessionStatus.inactive;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
if (filteredItems.isEmpty) {
|
||||||
|
return _buildHistoryContainer(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildHistoryHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Text(
|
||||||
|
_showActiveSessionsOnly
|
||||||
|
? tr('msg.userfront.audit.filtered_empty')
|
||||||
|
: tr('msg.userfront.dashboard.audit_empty'),
|
||||||
|
style: TextStyle(color: Colors.grey[600]),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (isWide) {
|
if (isWide) {
|
||||||
return _buildHistoryTable(state);
|
return _buildHistoryTable(state, filteredItems, sessionById);
|
||||||
}
|
}
|
||||||
return _buildHistoryList(state);
|
return _buildHistoryList(state, filteredItems, sessionById);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryHeader() {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
tr('ui.userfront.audit.filter.title'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
tr('msg.userfront.audit.filter.description'),
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
tr('ui.userfront.audit.filter.toggle_label'),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: _ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Transform.scale(
|
||||||
|
scale: 0.84,
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Switch(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
value: _showActiveSessionsOnly,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
_showActiveSessionsOnly = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistoryContainer({required Widget child}) {
|
Widget _buildHistoryContainer({required Widget child}) {
|
||||||
@@ -1764,6 +1660,116 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_HistorySessionStatus _historySessionStatusForLog(
|
||||||
|
AuditLogEntry log,
|
||||||
|
Map<String, UserSessionSummary> sessionById,
|
||||||
|
) {
|
||||||
|
final sessionId = log.sessionId.trim();
|
||||||
|
if (sessionId.isEmpty) {
|
||||||
|
return _HistorySessionStatus.inactive;
|
||||||
|
}
|
||||||
|
final session = sessionById[sessionId];
|
||||||
|
if (session == null) {
|
||||||
|
return _HistorySessionStatus.inactive;
|
||||||
|
}
|
||||||
|
if (session.isCurrent) {
|
||||||
|
return _HistorySessionStatus.current;
|
||||||
|
}
|
||||||
|
if (session.isActive) {
|
||||||
|
return _HistorySessionStatus.active;
|
||||||
|
}
|
||||||
|
return _HistorySessionStatus.inactive;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _historySessionStatusLabel(_HistorySessionStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case _HistorySessionStatus.current:
|
||||||
|
return tr('ui.userfront.dashboard.sessions.current_badge');
|
||||||
|
case _HistorySessionStatus.active:
|
||||||
|
return tr('ui.userfront.dashboard.sessions.active_badge');
|
||||||
|
case _HistorySessionStatus.inactive:
|
||||||
|
return tr('ui.common.status.inactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _historySessionStatusColor(_HistorySessionStatus status) {
|
||||||
|
switch (status) {
|
||||||
|
case _HistorySessionStatus.current:
|
||||||
|
return Colors.blueGrey;
|
||||||
|
case _HistorySessionStatus.active:
|
||||||
|
return Colors.green;
|
||||||
|
case _HistorySessionStatus.inactive:
|
||||||
|
return Colors.grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryStatusBadge(_HistorySessionStatus status) {
|
||||||
|
return SizedBox(
|
||||||
|
width: _historyStatusColumnWidth,
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _historySessionStatusColor(status),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_historySessionStatusLabel(status),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistorySessionActionCell(UserSessionSummary? session) {
|
||||||
|
if (session == null) {
|
||||||
|
return SizedBox(
|
||||||
|
width: _historyActionColumnWidth,
|
||||||
|
child: Center(
|
||||||
|
child: _selectableText(tr('ui.common.hyphen', fallback: '-')),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final isCurrent = session.isCurrent;
|
||||||
|
final canRevoke =
|
||||||
|
!isCurrent && _revokingSessionId == null && session.isActive;
|
||||||
|
return SizedBox(
|
||||||
|
width: _historyActionColumnWidth,
|
||||||
|
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')
|
||||||
|
: session.isActive
|
||||||
|
? tr('ui.userfront.dashboard.sessions.revoke.action')
|
||||||
|
: tr('ui.common.hyphen', fallback: '-'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
int _dashboardCardColumnCount(double maxWidth) {
|
int _dashboardCardColumnCount(double maxWidth) {
|
||||||
if (maxWidth > 1200) {
|
if (maxWidth > 1200) {
|
||||||
return 4;
|
return 4;
|
||||||
@@ -1779,10 +1785,30 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
crossAxisCount;
|
crossAxisCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistoryTable(AuthTimelineState state) {
|
Widget _buildCenteredHistoryHeader(String label, {double? width}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Center(child: Text(label, textAlign: TextAlign.center)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCenteredHistoryCell(Widget child, {double? width}) {
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Center(child: child),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryTable(
|
||||||
|
AuthTimelineState state,
|
||||||
|
List<AuditLogEntry> items,
|
||||||
|
Map<String, UserSessionSummary> sessionById,
|
||||||
|
) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
_buildHistoryHeader(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
LayoutBuilder(
|
LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final sessionColumnWidth = _historySessionColumnWidth(
|
final sessionColumnWidth = _historySessionColumnWidth(
|
||||||
@@ -1797,41 +1823,75 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
horizontalMargin: 12,
|
horizontalMargin: 12,
|
||||||
columns: [
|
columns: [
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: SizedBox(
|
label: _buildCenteredHistoryHeader(
|
||||||
width: sessionColumnWidth,
|
tr(
|
||||||
child: Text(
|
'ui.userfront.audit.table.session_id',
|
||||||
tr(
|
fallback: 'Session ID',
|
||||||
'ui.userfront.audit.table.session_id',
|
|
||||||
fallback: 'Session ID',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
width: sessionColumnWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.date')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.date'),
|
||||||
|
width: _historyDateColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.app')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.app'),
|
||||||
|
width: _historyAppColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(
|
label: _buildCenteredHistoryHeader(
|
||||||
tr('ui.userfront.audit.table.ip', fallback: 'IP'),
|
tr('ui.userfront.audit.table.ip', fallback: 'IP'),
|
||||||
|
width: _historyIpColumnWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.device')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.device'),
|
||||||
|
width: _historyDeviceColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.auth_method')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.browser'),
|
||||||
|
width: _historyBrowserColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.result')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.auth_method'),
|
||||||
|
width: _historyAuthMethodColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
DataColumn(
|
DataColumn(
|
||||||
label: Text(tr('ui.userfront.audit.table.status')),
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.result'),
|
||||||
|
width: _historyResultColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.status'),
|
||||||
|
width: _historyStatusColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataColumn(
|
||||||
|
label: _buildCenteredHistoryHeader(
|
||||||
|
tr('ui.userfront.audit.table.action'),
|
||||||
|
width: _historyActionColumnWidth,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
rows: state.items.map((log) {
|
rows: items.map((log) {
|
||||||
|
final matchedSession = sessionById[log.sessionId.trim()];
|
||||||
|
final sessionStatus = _historySessionStatusForLog(
|
||||||
|
log,
|
||||||
|
sessionById,
|
||||||
|
);
|
||||||
final statusLabel = log.status == 'success'
|
final statusLabel = log.status == 'success'
|
||||||
? tr('ui.common.status.success')
|
? tr('ui.common.status.success')
|
||||||
: tr('ui.common.status.failure');
|
: tr('ui.common.status.failure');
|
||||||
@@ -1844,45 +1904,86 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
final deviceLabel = _deviceLabelFromUserAgent(
|
final deviceLabel = _deviceLabelFromUserAgent(
|
||||||
log.userAgent,
|
log.userAgent,
|
||||||
);
|
);
|
||||||
|
final browserLabel = _sessionBrowserLabel(log.userAgent);
|
||||||
return DataRow(
|
return DataRow(
|
||||||
cells: [
|
cells: [
|
||||||
DataCell(
|
DataCell(
|
||||||
SizedBox(
|
_buildCenteredHistoryCell(
|
||||||
width: sessionColumnWidth,
|
_buildHistorySessionIdCell(
|
||||||
child: _buildHistorySessionIdCell(
|
|
||||||
log.sessionId.isEmpty
|
log.sessionId.isEmpty
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
: log.sessionId,
|
: log.sessionId,
|
||||||
sessionColumnWidth,
|
sessionColumnWidth,
|
||||||
),
|
),
|
||||||
|
width: sessionColumnWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataCell(
|
DataCell(
|
||||||
_selectableText(_formatDateTime(log.timestamp)),
|
_buildCenteredHistoryCell(
|
||||||
),
|
_selectableText(_formatDateTime(log.timestamp)),
|
||||||
DataCell(_buildAppCell(log)),
|
width: _historyDateColumnWidth,
|
||||||
DataCell(
|
|
||||||
_selectableText(
|
|
||||||
log.ipAddress.isEmpty
|
|
||||||
? tr('ui.common.hyphen', fallback: '-')
|
|
||||||
: log.ipAddress,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataCell(_selectableText(deviceLabel)),
|
|
||||||
DataCell(_buildAuthMethodCell(log, authMethod)),
|
|
||||||
DataCell(
|
DataCell(
|
||||||
_selectableText(
|
_buildCenteredHistoryCell(
|
||||||
statusLabel,
|
_buildAppCell(log),
|
||||||
style: TextStyle(
|
width: _historyAppColumnWidth,
|
||||||
color: statusColor,
|
),
|
||||||
fontWeight: FontWeight.w600,
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_selectableText(
|
||||||
|
log.ipAddress.isEmpty
|
||||||
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
|
: log.ipAddress,
|
||||||
),
|
),
|
||||||
|
width: _historyIpColumnWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
DataCell(
|
DataCell(
|
||||||
_selectableText(
|
_buildCenteredHistoryCell(
|
||||||
tr('ui.userfront.audit.table.pending'),
|
_singleLineText(deviceLabel),
|
||||||
style: const TextStyle(color: Colors.grey),
|
width: _historyDeviceColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_selectableText(
|
||||||
|
browserLabel.isEmpty
|
||||||
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
|
: browserLabel,
|
||||||
|
),
|
||||||
|
width: _historyBrowserColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_buildAuthMethodCell(log, authMethod),
|
||||||
|
width: _historyAuthMethodColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_selectableText(
|
||||||
|
statusLabel,
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
width: _historyResultColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_buildHistoryStatusBadge(sessionStatus),
|
||||||
|
width: _historyStatusColumnWidth,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
DataCell(
|
||||||
|
_buildCenteredHistoryCell(
|
||||||
|
_buildHistorySessionActionCell(matchedSession),
|
||||||
|
width: _historyActionColumnWidth,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1910,6 +2011,10 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _compactSessionId(String sessionId) {
|
String _compactSessionId(String sessionId) {
|
||||||
|
final parts = sessionId.split('-');
|
||||||
|
if (parts.length >= 4) {
|
||||||
|
return '${parts.take(3).join('-')}-...';
|
||||||
|
}
|
||||||
if (sessionId.length <= _historySessionMinVisibleChars) {
|
if (sessionId.length <= _historySessionMinVisibleChars) {
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
@@ -1917,26 +2022,32 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) {
|
Widget _buildHistorySessionIdCell(String sessionId, double columnWidth) {
|
||||||
final compactMode = columnWidth <= _historySessionMinWidth + 0.5;
|
final displayText = _compactSessionId(sessionId);
|
||||||
final displayText = compactMode ? _compactSessionId(sessionId) : sessionId;
|
|
||||||
final textWidget = Text(
|
final textWidget = Text(
|
||||||
displayText,
|
displayText,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
softWrap: false,
|
softWrap: false,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (displayText == sessionId) {
|
if (displayText == sessionId || sessionId.isEmpty) {
|
||||||
return textWidget;
|
return textWidget;
|
||||||
}
|
}
|
||||||
return Tooltip(message: sessionId, child: textWidget);
|
return Tooltip(message: sessionId, child: textWidget);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHistoryList(AuthTimelineState state) {
|
Widget _buildHistoryList(
|
||||||
|
AuthTimelineState state,
|
||||||
|
List<AuditLogEntry> items,
|
||||||
|
Map<String, UserSessionSummary> sessionById,
|
||||||
|
) {
|
||||||
return _buildHistoryContainer(
|
return _buildHistoryContainer(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
for (final log in state.items)
|
_buildHistoryHeader(),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
for (final log in items)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
@@ -1948,6 +2059,15 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildHistoryStatusBadge(
|
||||||
|
_historySessionStatusForLog(log, sessionById),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -2008,6 +2128,16 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_selectableText(
|
||||||
|
tr(
|
||||||
|
'msg.userfront.audit.browser',
|
||||||
|
params: {
|
||||||
|
'value': _sessionBrowserLabel(log.userAgent).isEmpty
|
||||||
|
? tr('ui.common.hyphen', fallback: '-')
|
||||||
|
: _sessionBrowserLabel(log.userAgent),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
_buildAuthMethodLine(
|
_buildAuthMethodLine(
|
||||||
log,
|
log,
|
||||||
log.authMethod.isNotEmpty
|
log.authMethod.isNotEmpty
|
||||||
@@ -2025,8 +2155,18 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
_selectableText(
|
_selectableText(
|
||||||
tr('msg.userfront.audit.status'),
|
tr(
|
||||||
style: TextStyle(color: Colors.grey[600]),
|
'msg.userfront.audit.status',
|
||||||
|
params: {
|
||||||
|
'value': _historySessionStatusLabel(
|
||||||
|
_historySessionStatusForLog(log, sessionById),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildHistorySessionActionCell(
|
||||||
|
sessionById[log.sessionId.trim()],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2143,6 +2283,8 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum _HistorySessionStatus { current, active, inactive }
|
||||||
|
|
||||||
class _ActivityItem {
|
class _ActivityItem {
|
||||||
final String clientId;
|
final String clientId;
|
||||||
final String appName;
|
final String appName;
|
||||||
|
|||||||
@@ -413,9 +413,11 @@ const Map<String, String> koStrings = {
|
|||||||
"msg.dev.sidebar.notice": "개발자 전용 콘솔입니다.",
|
"msg.dev.sidebar.notice": "개발자 전용 콘솔입니다.",
|
||||||
"msg.dev.sidebar.notice_detail": "연동 앱 등록 및 관리를 수행할 수 있습니다.",
|
"msg.dev.sidebar.notice_detail": "연동 앱 등록 및 관리를 수행할 수 있습니다.",
|
||||||
"msg.info.saved_success": "저장이 완료되었습니다.",
|
"msg.info.saved_success": "저장이 완료되었습니다.",
|
||||||
|
"msg.userfront.audit.browser": "브라우저: {{value}}",
|
||||||
"msg.userfront.audit.date": "접속일자: {{value}}",
|
"msg.userfront.audit.date": "접속일자: {{value}}",
|
||||||
"msg.userfront.audit.device": "접속환경: {{value}}",
|
"msg.userfront.audit.device": "접속환경: {{value}}",
|
||||||
"msg.userfront.audit.end": "더 이상 항목이 없습니다.",
|
"msg.userfront.audit.end": "더 이상 항목이 없습니다.",
|
||||||
|
"msg.userfront.audit.filter.description": "활성화된 세션만 보려면 토글을 켜주세요.",
|
||||||
"msg.userfront.audit.ip": "접속 IP: {{value}}",
|
"msg.userfront.audit.ip": "접속 IP: {{value}}",
|
||||||
"msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.",
|
"msg.userfront.audit.load_more_error": "더 불러오지 못했습니다.",
|
||||||
"msg.userfront.audit.result": "인증결과: {{value}}",
|
"msg.userfront.audit.result": "인증결과: {{value}}",
|
||||||
@@ -1691,8 +1693,12 @@ const Map<String, String> koStrings = {
|
|||||||
"ui.userfront.app_label.baron": "Baron 로그인",
|
"ui.userfront.app_label.baron": "Baron 로그인",
|
||||||
"ui.userfront.app_label.dev_console": "Dev Console",
|
"ui.userfront.app_label.dev_console": "Dev Console",
|
||||||
"ui.userfront.app_title": "Baron SW 포탈",
|
"ui.userfront.app_title": "Baron SW 포탈",
|
||||||
|
"ui.userfront.audit.filter.title": "내 활동 관리",
|
||||||
|
"ui.userfront.audit.filter.toggle_label": "활성 세션만 보기",
|
||||||
|
"ui.userfront.audit.table.action": "관리",
|
||||||
"ui.userfront.audit.table.app": "애플리케이션",
|
"ui.userfront.audit.table.app": "애플리케이션",
|
||||||
"ui.userfront.audit.table.auth_method": "인증수단",
|
"ui.userfront.audit.table.auth_method": "인증수단",
|
||||||
|
"ui.userfront.audit.table.browser": "브라우저",
|
||||||
"ui.userfront.audit.table.date": "접속일자",
|
"ui.userfront.audit.table.date": "접속일자",
|
||||||
"ui.userfront.audit.table.device": "접속환경",
|
"ui.userfront.audit.table.device": "접속환경",
|
||||||
"ui.userfront.audit.table.ip": "IP",
|
"ui.userfront.audit.table.ip": "IP",
|
||||||
@@ -1715,7 +1721,7 @@ const Map<String, String> koStrings = {
|
|||||||
"ui.userfront.dashboard.revoke.title": "연동 해지",
|
"ui.userfront.dashboard.revoke.title": "연동 해지",
|
||||||
"ui.userfront.dashboard.scopes.title": "권한 (Scopes)",
|
"ui.userfront.dashboard.scopes.title": "권한 (Scopes)",
|
||||||
"ui.userfront.dashboard.sessions.active_badge": "활성화",
|
"ui.userfront.dashboard.sessions.active_badge": "활성화",
|
||||||
"ui.userfront.dashboard.sessions.current_badge": "현재 접속중",
|
"ui.userfront.dashboard.sessions.current_badge": "접속중",
|
||||||
"ui.userfront.dashboard.sessions.current_disabled": "현재 세션",
|
"ui.userfront.dashboard.sessions.current_disabled": "현재 세션",
|
||||||
"ui.userfront.dashboard.sessions.revoke.action": "세션 종료",
|
"ui.userfront.dashboard.sessions.revoke.action": "세션 종료",
|
||||||
"ui.userfront.dashboard.sessions.revoke.title": "세션 종료",
|
"ui.userfront.dashboard.sessions.revoke.title": "세션 종료",
|
||||||
@@ -2312,9 +2318,12 @@ const Map<String, String> enStrings = {
|
|||||||
"msg.dev.sidebar.notice": "Developer Console",
|
"msg.dev.sidebar.notice": "Developer Console",
|
||||||
"msg.dev.sidebar.notice_detail": "Register and manage client applications.",
|
"msg.dev.sidebar.notice_detail": "Register and manage client applications.",
|
||||||
"msg.info.saved_success": "Saved successfully.",
|
"msg.info.saved_success": "Saved successfully.",
|
||||||
|
"msg.userfront.audit.browser": "Browser: {{value}}",
|
||||||
"msg.userfront.audit.date": "Date: {{value}}",
|
"msg.userfront.audit.date": "Date: {{value}}",
|
||||||
"msg.userfront.audit.device": "Device: {{value}}",
|
"msg.userfront.audit.device": "Device: {{value}}",
|
||||||
"msg.userfront.audit.end": "No more items to show.",
|
"msg.userfront.audit.end": "No more items to show.",
|
||||||
|
"msg.userfront.audit.filter.description":
|
||||||
|
"Toggle to view only active sessions.",
|
||||||
"msg.userfront.audit.ip": "IP address: {{value}}",
|
"msg.userfront.audit.ip": "IP address: {{value}}",
|
||||||
"msg.userfront.audit.load_more_error": "Could not load more history.",
|
"msg.userfront.audit.load_more_error": "Could not load more history.",
|
||||||
"msg.userfront.audit.result": "Result: {{value}}",
|
"msg.userfront.audit.result": "Result: {{value}}",
|
||||||
@@ -3694,8 +3703,12 @@ const Map<String, String> enStrings = {
|
|||||||
"ui.userfront.app_label.baron": "Baron",
|
"ui.userfront.app_label.baron": "Baron",
|
||||||
"ui.userfront.app_label.dev_console": "Dev Console",
|
"ui.userfront.app_label.dev_console": "Dev Console",
|
||||||
"ui.userfront.app_title": "Baron SW Portal",
|
"ui.userfront.app_title": "Baron SW Portal",
|
||||||
|
"ui.userfront.audit.filter.title": "Manage My Activity",
|
||||||
|
"ui.userfront.audit.filter.toggle_label": "Show active sessions only",
|
||||||
|
"ui.userfront.audit.table.action": "Action",
|
||||||
"ui.userfront.audit.table.app": "App",
|
"ui.userfront.audit.table.app": "App",
|
||||||
"ui.userfront.audit.table.auth_method": "Auth Method",
|
"ui.userfront.audit.table.auth_method": "Auth Method",
|
||||||
|
"ui.userfront.audit.table.browser": "Browser",
|
||||||
"ui.userfront.audit.table.date": "Date",
|
"ui.userfront.audit.table.date": "Date",
|
||||||
"ui.userfront.audit.table.device": "Device",
|
"ui.userfront.audit.table.device": "Device",
|
||||||
"ui.userfront.audit.table.ip": "IP",
|
"ui.userfront.audit.table.ip": "IP",
|
||||||
|
|||||||
Reference in New Issue
Block a user