1
0
forked from baron/baron-sso

Merge branch 'dev' into fix/rebac-env-sync-issue

This commit is contained in:
2026-04-10 13:52:07 +09:00
79 changed files with 9316 additions and 1606 deletions

View File

@@ -107,12 +107,17 @@ logs-app:
docker compose -f $(COMPOSE_APP) logs -f
# --- 로컬 통합 코드 체크 ---
PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE
PLAYWRIGHT_FIREFOX_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/firefox-1509/INSTALLATION_COMPLETE
PLAYWRIGHT_WEBKIT_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/webkit-2248/INSTALLATION_COMPLETE
ifeq ($(CI),)
PLAYWRIGHT_INSTALL_ALL := npx playwright install
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install chromium
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
else
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
PLAYWRIGHT_INSTALL_CHROMIUM := npx playwright install --with-deps chromium
PLAYWRIGHT_INSTALL_ALL := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_FIREFOX_COMPLETE)" ] && [ -f "$(PLAYWRIGHT_WEBKIT_COMPLETE)" ]; then echo "Playwright browsers already installed"; else npx playwright install --with-deps; fi'
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install --with-deps chromium; fi'
endif
.PHONY: code-check code-check-lint code-check-test-jobs code-check-i18n code-check-i18n-values code-check-go-lint code-check-sync-userfront-locales code-check-userfront-install code-check-userfront-lint code-check-front-lint code-check-backend-tests code-check-userfront-tests code-check-adminfront-tests code-check-devfront-tests code-check-userfront-e2e-tests

View File

@@ -7,7 +7,7 @@
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"lint": "biome check .",
"lint:fix": "biome check . --write",

View File

@@ -19,7 +19,10 @@ import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher";
@@ -221,6 +224,52 @@ function AppLayout() {
isSessionExpiryEnabled,
]);
useEffect(() => {
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {

View File

@@ -14,7 +14,14 @@ function AuthCallbackPage() {
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
navigate("/", { replace: true });
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });

View File

@@ -1,5 +1,7 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -11,10 +13,40 @@ import {
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = () => {
// OIDC client-side authentication flow started here
auth.signinRedirect();
void auth.signinRedirect({
state: {
returnTo: "/",
},
});
};
return (

View File

@@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = {
scope: "openid offline_access profile email", // offline_access for refresh token
post_logout_redirect_uri: window.location.origin,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
automaticSilentRenew: false,
};
export const userManager = new UserManager({

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
SESSION_RENEW_THRESHOLD_MS,
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "./sessionSliding";
describe("shouldAttemptSlidingSessionRenew", () => {
@@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => {
).toBe(false);
});
});
describe("shouldAttemptUnlimitedSessionRenew", () => {
const nowMs = 1_700_000_000_000;
it("returns false when unlimited mode is not active", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: true,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
it("returns true near expiry when session expiry management is disabled", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS - 1_000) / 1000,
),
nowMs,
isEnabled: false,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(true);
});
it("returns false when the token still has enough remaining lifetime", () => {
expect(
shouldAttemptUnlimitedSessionRenew({
expiresAtSec: Math.floor(
(nowMs + SESSION_RENEW_THRESHOLD_MS + 1_000) / 1000,
),
nowMs,
isEnabled: false,
isAuthenticated: true,
isLoading: false,
isRenewInFlight: false,
lastAttemptAtMs: 0,
}),
).toBe(false);
});
});

View File

@@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}

View File

@@ -1501,6 +1501,7 @@ ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""

View File

@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
envPrefix: ["VITE_", "USERFRONT_"],
server: {
host: "0.0.0.0",
host: "127.0.0.1",
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {
"/api": {
@@ -15,7 +15,7 @@ export default defineConfig({
},
},
preview: {
host: "0.0.0.0",
host: "127.0.0.1",
port: 5173,
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {

View File

@@ -121,6 +121,18 @@ func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identity
return nil
}
func (m *e2eMockKratosAdminService) ListIdentitySessions(ctx context.Context, identityID string) ([]service.KratosSession, error) {
return nil, nil
}
func (m *e2eMockKratosAdminService) GetSession(ctx context.Context, sessionID string) (*service.KratosSession, error) {
return nil, nil
}
func (m *e2eMockKratosAdminService) DeleteSession(ctx context.Context, sessionID string) error {
return nil
}
func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App {
app := fiber.New(fiber.Config{
DisableStartupMessage: true,

View File

@@ -582,6 +582,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)

File diff suppressed because it is too large Load Diff

View File

@@ -80,6 +80,7 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error
}
return args.Error(0)
}
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error {
args := m.Called(ctx, user)
if m.createCalled != nil {
@@ -87,6 +88,7 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error
}
return args.Error(0)
}
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
@@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{
"client": map[string]interface{}{
"client_id": "client-active",
"client_name": "Active App",
"client_id": "devfront",
"client_name": "DevFront",
"redirect_uris": []string{
"https://active.example.com/callback",
},
},
"granted_scope": []string{"openid"},
"handled_at": time.Now().Format(time.RFC3339),
"grant_scope": []string{"openid", "profile"},
"handled_at": time.Now().Format(time.RFC3339),
},
}), nil
}
@@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
t.Setenv("DEVFRONT_URL", "http://localhost:5174")
app := newLinkedRpTestApp(h)
@@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
var res struct {
Items []struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
Scopes []string `json:"scopes"`
InitURL string `json:"init_url"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
@@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
statusMap[item.ID] = item.Status
}
assert.Equal(t, "active", statusMap["client-active"])
assert.Equal(t, "active", statusMap["devfront"])
assert.Equal(t, "inactive", statusMap["client-consent"])
assert.Equal(t, "inactive", statusMap["client-audit"])
var activeInitURL string
for _, item := range res.Items {
if item.ID == "devfront" {
activeInitURL = item.InitURL
break
}
}
parsedInitURL, err := url.Parse(activeInitURL)
assert.NoError(t, err)
assert.Equal(t, "http", parsedInitURL.Scheme)
assert.Equal(t, "localhost:5174", parsedInitURL.Host)
assert.Equal(t, "/login", parsedInitURL.Path)
assert.Equal(t, "1", parsedInitURL.Query().Get("auto"))
assert.Equal(t, "/clients", parsedInitURL.Query().Get("returnTo"))
}
func TestListLinkedRps_EnrichesLogoFromHydraClientWhenConsentSessionOmitsMetadata(t *testing.T) {
transport := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
if r.URL.Path == "/sessions/whoami" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"identity": map[string]interface{}{
"id": "user-123",
},
}), nil
}
case "hydra.test":
if r.URL.Path == "/oauth2/auth/sessions/consent" {
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
{
"client": map[string]interface{}{
"client_id": "gitea-client",
"client_name": "Gitea",
"redirect_uris": []string{
"https://gitea.example.com/callback",
},
},
"grant_scope": []string{"openid", "profile"},
"handled_at": time.Now().Format(time.RFC3339),
},
}), nil
}
if r.URL.Path == "/clients/gitea-client" {
return httpJSONAny(r, http.StatusOK, map[string]interface{}{
"client_id": "gitea-client",
"client_name": "Gitea",
"redirect_uris": []string{
"https://gitea.example.com/callback",
},
"metadata": map[string]interface{}{
"logo_url": "https://cdn.example.com/gitea.svg",
},
}), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})
client := &http.Client{Transport: transport}
origDefault := http.DefaultClient
http.DefaultClient = client
defer func() {
http.DefaultClient = origDefault
}()
h := &AuthHandler{
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
KratosAdmin: new(MockKratosAdminService),
}
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
t.Setenv("KRATOS_ADMIN_URL", "http://kratos.test")
t.Setenv("HYDRA_PUBLIC_URL", "https://sso.example.com/oidc")
app := newLinkedRpTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/user/rp/linked", nil)
req.Header.Set("Cookie", "ory_kratos_session=valid")
resp, err := app.Test(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var res struct {
Items []struct {
ID string `json:"id"`
Logo string `json:"logo"`
} `json:"items"`
}
json.NewDecoder(resp.Body).Decode(&res)
assert.Len(t, res.Items, 1)
assert.Equal(t, "gitea-client", res.Items[0].ID)
assert.Equal(t, "https://cdn.example.com/gitea.svg", res.Items[0].Logo)
}

View File

@@ -7,6 +7,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/middleware"
"baron-sso-backend/internal/service"
"bytes"
"context"
@@ -122,6 +123,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 {
@@ -616,6 +638,156 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
}
}
func TestPasswordLogin_OIDC_AuditIncludesClientMetadata(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"},
Subject: "kratos-identity-id",
}, nil)
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login") && r.Method == http.MethodGet:
json.NewEncoder(w).Encode(domain.HydraLoginRequest{
Challenge: "challenge-123",
Client: domain.HydraClient{
ClientID: "devfront",
ClientName: "DevFront",
Metadata: map[string]interface{}{"status": "active"},
},
})
case strings.Contains(r.URL.Path, "/oauth2/auth/requests/login/accept") && r.Method == http.MethodPut:
json.NewEncoder(w).Encode(map[string]string{"redirect_to": "http://rp/cb"})
default:
http.NotFound(w, r)
}
})
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil)
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
IdpProvider: mockIdp,
KratosAdmin: mockKratos,
AuditRepo: auditRepo,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
},
}
app := fiber.New()
app.Use(middleware.AuditMiddleware(middleware.AuditConfig{
Repo: auditRepo,
BodyDump: true,
}))
app.Post("/api/v1/auth/password/login", h.PasswordLogin)
body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com",
"password": "password",
"login_challenge": "challenge-123",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
}
if len(auditRepo.logs) != 1 {
t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs))
}
log := auditRepo.logs[0]
if log.EventType != "POST /api/v1/auth/password/login" {
t.Fatalf("expected password login audit event, got %q", log.EventType)
}
if log.UserID != "kratos-identity-id" {
t.Fatalf("expected audit user_id kratos-identity-id, got %q", log.UserID)
}
details, err := parseAuditDetails(log.Details)
if err != nil {
t.Fatalf("failed to parse audit details: %v", err)
}
if got, _ := details["client_id"].(string); got != "devfront" {
t.Fatalf("expected client_id devfront, got %v", details["client_id"])
}
if got, _ := details["client_name"].(string); got != "DevFront" {
t.Fatalf("expected client_name DevFront, got %v", details["client_name"])
}
}
func TestPasswordLogin_UserFront_AuditIncludesDefaultClientMetadata(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "user@example.com", "password").Return(&domain.AuthInfo{
SessionToken: &domain.Token{JWT: "valid-jwt", SessionID: "session-123"},
Subject: "kratos-identity-id",
}, nil)
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil)
auditRepo := &mockAuditRepo{}
h := &AuthHandler{
IdpProvider: mockIdp,
KratosAdmin: mockKratos,
AuditRepo: auditRepo,
}
app := fiber.New()
app.Use(middleware.AuditMiddleware(middleware.AuditConfig{
Repo: auditRepo,
BodyDump: true,
}))
app.Post("/api/v1/auth/password/login", h.PasswordLogin)
body, _ := json.Marshal(map[string]string{
"loginId": "user@example.com",
"password": "password",
})
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/password/login", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
t.Fatalf("expected 200, got %d, body: %s", resp.StatusCode, string(bodyBytes))
}
if len(auditRepo.logs) != 1 {
t.Fatalf("expected 1 audit log, got %d", len(auditRepo.logs))
}
if auditRepo.logs[0].UserID != "kratos-identity-id" {
t.Fatalf("expected audit user_id kratos-identity-id, got %q", auditRepo.logs[0].UserID)
}
details, err := parseAuditDetails(auditRepo.logs[0].Details)
if err != nil {
t.Fatalf("failed to parse audit details: %v", err)
}
if got, _ := details["client_id"].(string); got != "userfront" {
t.Fatalf("expected client_id userfront, got %v", details["client_id"])
}
if got, _ := details["client_name"].(string); got != "UserFront" {
t.Fatalf("expected client_name UserFront, got %v", details["client_name"])
}
}
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
mockIdp := new(MockIdentityProvider)
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{

View File

@@ -0,0 +1,685 @@
package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/service"
"context"
"encoding/json"
"io"
"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 TestListMySessions_UsesConsentGrantForAppName(t *testing.T) {
now := time.Date(2026, 4, 2, 4, 40, 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
}))
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: "c7c721ea-session",
Active: true,
AuthenticatedAt: now.Add(-5 * time.Minute),
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
},
}, nil).Once()
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
UserID: "user-123",
EventType: "consent.granted",
SessionID: "c7c721ea-session",
Timestamp: now,
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session","approved_session_id":"c7c721ea-session"}`,
},
},
}
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"`
AppName string `json:"app_name"`
ClientID string `json:"client_id"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
assert.NoError(t, err)
if assert.Len(t, body.Items, 2) {
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
assert.Equal(t, "DevFront", body.Items[1].AppName)
assert.Equal(t, "devfront", body.Items[1].ClientID)
}
mockKratos.AssertExpectations(t)
}
func TestListMySessions_PreservesAppNameFromOlderConsentGrant(t *testing.T) {
now := time.Date(2026, 4, 2, 4, 40, 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
}))
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: "c7c721ea-session",
Active: true,
AuthenticatedAt: now.Add(-5 * time.Minute),
ExpiresAt: now.Add(23*time.Hour + 55*time.Minute),
},
}, nil).Once()
auditRepo := &mockAuditRepo{
logs: []domain.AuditLog{
{
UserID: "user-123",
EventType: "consent.granted",
SessionID: "c7c721ea-session",
Timestamp: now.Add(-30 * time.Second),
IPAddress: "203.0.113.10",
Details: `{"client_id":"devfront","client_name":"DevFront","session_id":"c7c721ea-session"}`,
},
{
UserID: "user-123",
EventType: "login_success",
SessionID: "c7c721ea-session",
Timestamp: now,
IPAddress: "10.0.0.12",
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"`
AppName string `json:"app_name"`
ClientID string `json:"client_id"`
IPAddress string `json:"ip_address"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
assert.NoError(t, err)
if assert.Len(t, body.Items, 2) {
assert.Equal(t, "c7c721ea-session", body.Items[1].SessionID)
assert.Equal(t, "DevFront", body.Items[1].AppName)
assert.Equal(t, "devfront", body.Items[1].ClientID)
assert.Equal(t, "203.0.113.10", body.Items[1].IPAddress)
}
mockKratos.AssertExpectations(t)
}
func TestListMySessions_CurrentSessionFallsBackToRequestMetadata(t *testing.T) {
now := time.Date(2026, 4, 6, 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),
},
}, nil).Once()
h := &AuthHandler{
KratosAdmin: mockKratos,
AuditRepo: &mockAuditRepo{},
}
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")
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Chrome/146.0.0.0 Safari/537.36")
req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25")
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"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
ClientID string `json:"client_id"`
AppName string `json:"app_name"`
} `json:"items"`
}
err = json.NewDecoder(resp.Body).Decode(&body)
assert.NoError(t, err)
if assert.Len(t, body.Items, 1) {
assert.Equal(t, "current-sid", body.Items[0].SessionID)
assert.True(t, body.Items[0].IsCurrent)
assert.Equal(t, "203.0.113.25", body.Items[0].IPAddress)
assert.Contains(t, body.Items[0].UserAgent, "Mozilla/5.0")
assert.Equal(t, "userfront", body.Items[0].ClientID)
assert.Equal(t, "UserFront", body.Items[0].AppName)
}
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_Success(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
var hydraRevokeCalls int
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
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
}
case "hydra.test":
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
if r.URL.Query().Get("subject") != "user-123" {
t.Fatalf("unexpected revoke subject: %s", r.URL.Query().Get("subject"))
}
if r.URL.Query().Get("client") != "devfront" {
t.Fatalf("unexpected revoke client: %s", r.URL.Query().Get("client"))
}
hydraRevokeCalls++
return httpResponse(r, http.StatusNoContent, ""), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
setDefaultHTTPClientForTest(t, client.Transport)
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,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
UserID: "user-123",
EventType: "POST /api/v1/auth/oidc/login/accept",
SessionID: "target-sid",
Details: `{"client_id":"devfront","client_name":"Devfront"}`,
})
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, 2) {
assert.Equal(t, "session.revoked", auditRepo.logs[len(auditRepo.logs)-1].EventType)
assert.Equal(t, "user-123", auditRepo.logs[len(auditRepo.logs)-1].UserID)
assert.Equal(t, "current-sid", auditRepo.logs[len(auditRepo.logs)-1].SessionID)
assert.Contains(t, auditRepo.logs[len(auditRepo.logs)-1].Details, "target-sid")
}
assert.Equal(t, 1, hydraRevokeCalls)
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_DoesNotRevokeAllHydraSessionsWhenClientBindingMissing(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
var hydraRevokeCalls int
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
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
}
case "hydra.test":
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
hydraRevokeCalls++
return httpResponse(r, http.StatusNoContent, ""), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
setDefaultHTTPClientForTest(t, client.Transport)
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,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
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)
assert.Equal(t, 0, hydraRevokeCalls)
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.Contains(t, auditRepo.logs[0].Details, "target-sid")
}
mockKratos.AssertExpectations(t)
}
func TestDeleteMySession_RevokesHydraClientBoundFromPasswordLoginAudit(t *testing.T) {
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
var hydraRevokeCalls int
var revokedClient string
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch r.URL.Host {
case "kratos.test":
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
}
case "hydra.test":
if r.Method == http.MethodDelete && r.URL.Path == "/oauth2/auth/sessions/consent" {
revokedClient = r.URL.Query().Get("client")
hydraRevokeCalls++
return httpResponse(r, http.StatusNoContent, ""), nil
}
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
setDefaultHTTPClientForTest(t, client.Transport)
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,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
auditRepo.logs = append(auditRepo.logs, domain.AuditLog{
UserID: "user-123",
EventType: "POST /api/v1/auth/password/login",
SessionID: "target-sid",
Details: `{"client_id":"adminfront","client_name":"AdminFront","session_id":"target-sid"}`,
})
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)
assert.Equal(t, 1, hydraRevokeCalls)
assert.Equal(t, "adminfront", revokedClient)
mockKratos.AssertExpectations(t)
}
func TestGetHydraProfile_RejectsInactiveLinkedSession(t *testing.T) {
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.URL.Host == "hydra.test" && r.URL.Path == "/oauth2/introspect" {
body, _ := io.ReadAll(r.Body)
if string(body) != "token=opaque-token" {
t.Fatalf("unexpected introspect body: %s", string(body))
}
return httpJSONAny(r, http.StatusOK, map[string]any{
"active": true,
"sub": "user-123",
"client_id": "devfront",
"ext": map[string]any{
"session_id": "target-sid",
},
}), nil
}
return httpResponse(r, http.StatusNotFound, "not found"), nil
})}
mockKratos := new(MockKratosAdminService)
mockKratos.On("GetSession", mock.Anything, "target-sid").Return(&service.KratosSession{
ID: "target-sid",
Active: false,
Identity: &service.KratosIdentity{
ID: "user-123",
},
}, nil).Once()
h := &AuthHandler{
KratosAdmin: mockKratos,
Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test",
HTTPClient: client,
},
}
profile, err := h.getHydraProfile(context.Background(), "opaque-token")
assert.Nil(t, profile)
assert.Error(t, err)
assert.Contains(t, err.Error(), "inactive")
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)
}
}

View File

@@ -115,6 +115,25 @@ func (m *mockAuditRepo) CountActiveSessionsSince(ctx context.Context, since time
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 ---
type mockConsentRepo struct {

View File

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

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"log/slog"
"reflect"
"strings"
"sync"
"time"
@@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
}
func extractClientIP(c *fiber.Ctx) string {
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
parts := strings.Split(forwarded, ",")
if len(parts) > 0 {
if ip := strings.TrimSpace(parts[0]); ip != "" {
return ip
}
}
}
if realIP := strings.TrimSpace(c.Get("X-Real-IP")); realIP != "" {
return realIP
}
return c.IP()
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
}

View File

@@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) {
mockRepo.AssertExpectations(t)
})
t.Run("POST request - Prefer public forwarded IP", func(t *testing.T) {
app := fiber.New()
mockRepo := new(MockAuditRepository)
app.Use(AuditMiddleware(AuditConfig{
Repo: mockRepo,
}))
app.Post("/test", func(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusOK)
})
mockRepo.On("Create", mock.MatchedBy(func(log *domain.AuditLog) bool {
return log.IPAddress == "203.0.113.25"
})).Return(nil)
req := httptest.NewRequest("POST", "/test", nil)
req.Header.Set("X-Forwarded-For", "100.100.100.1, 203.0.113.25")
resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusOK, resp.StatusCode)
mockRepo.AssertExpectations(t)
})
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
app := fiber.New()
mockRepo := new(MockAuditRepository)

View File

@@ -264,6 +264,8 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
}
if clientID != "" {
params["client"] = clientID
} else {
params["all"] = "true"
}
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
if err != nil {

View File

@@ -28,6 +28,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)
@@ -245,7 +260,7 @@ func (s *kratosAdminService) CreateUser(ctx context.Context, user *domain.Broker
if user == nil {
return "", fmt.Errorf("kratos admin: user payload is nil")
}
traits := map[string]interface{}{
"email": user.Email,
"name": user.Name,

View File

@@ -116,5 +116,3 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom
args := m.Called(ctx, user, password)
return args.String(0), args.Error(1)
}

View File

@@ -150,6 +150,7 @@ func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs
}
return args.Get(0).(map[string]int64), args.Error(1)
}
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
args := m.Called(ctx, codes)
if args.Get(0) == nil {

View File

@@ -0,0 +1,87 @@
package utils
import (
"net"
"strings"
)
// ResolveClientIP selects the best client IP from proxy headers and the remote address.
// It prefers a public IP from X-Forwarded-For, then X-Real-IP, and finally the remote IP.
func ResolveClientIP(forwardedFor, realIP, remoteIP string) string {
forwardedCandidates := splitClientIPs(forwardedFor)
if ip := firstPublicIP(forwardedCandidates); ip != "" {
return ip
}
if ip := normalizeIP(realIP); ip != "" && !IsPrivateOrReservedIP(ip) {
return ip
}
if ip := normalizeIP(remoteIP); ip != "" && !IsPrivateOrReservedIP(ip) {
return ip
}
if len(forwardedCandidates) > 0 {
return forwardedCandidates[0]
}
if ip := normalizeIP(realIP); ip != "" {
return ip
}
return normalizeIP(remoteIP)
}
// IsPrivateOrReservedIP reports whether the IP is private or from a non-public network range.
func IsPrivateOrReservedIP(raw string) bool {
ip := net.ParseIP(strings.TrimSpace(raw))
if ip == nil {
return false
}
if ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() {
return true
}
for _, cidr := range []string{
"100.64.0.0/10",
"fc00::/7",
} {
_, network, err := net.ParseCIDR(cidr)
if err == nil && network.Contains(ip) {
return true
}
}
return false
}
func splitClientIPs(forwardedFor string) []string {
if strings.TrimSpace(forwardedFor) == "" {
return nil
}
parts := strings.Split(forwardedFor, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
if ip := normalizeIP(part); ip != "" {
result = append(result, ip)
}
}
return result
}
func firstPublicIP(candidates []string) string {
for _, candidate := range candidates {
if !IsPrivateOrReservedIP(candidate) {
return candidate
}
}
return ""
}
func normalizeIP(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if host, _, err := net.SplitHostPort(raw); err == nil {
raw = host
}
ip := net.ParseIP(raw)
if ip == nil {
return ""
}
return ip.String()
}

View File

@@ -0,0 +1,24 @@
package utils
import "testing"
func TestResolveClientIP_PrefersPublicForwardedIP(t *testing.T) {
got := ResolveClientIP("100.100.100.1, 203.0.113.25, 10.0.0.2", "", "172.18.0.5")
if got != "203.0.113.25" {
t.Fatalf("expected public forwarded IP, got %q", got)
}
}
func TestResolveClientIP_FallsBackToFirstForwardedWhenAllPrivate(t *testing.T) {
got := ResolveClientIP("100.100.100.1, 10.0.0.2", "192.168.0.10", "172.18.0.5")
if got != "100.100.100.1" {
t.Fatalf("expected first forwarded private IP, got %q", got)
}
}
func TestResolveClientIP_PrefersPublicRealIPOverPrivateForwarded(t *testing.T) {
got := ResolveClientIP("100.100.100.1, 10.0.0.2", "198.51.100.7", "172.18.0.5")
if got != "198.51.100.7" {
t.Fatalf("expected public real IP, got %q", got)
}
}

View File

@@ -7,7 +7,7 @@
"node": ">=24.0.0"
},
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"lint": "biome check .",
"preview": "vite preview",

View File

@@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
@@ -151,6 +154,52 @@ function AppLayout() {
isSessionExpiryEnabled,
]);
useEffect(() => {
const maybeKeepSessionAlive = async () => {
const now = Date.now();
if (
!shouldAttemptUnlimitedSessionRenew({
expiresAtSec: auth.user?.expires_at,
nowMs: now,
isEnabled: isSessionExpiryEnabled,
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
isRenewInFlight: isRenewInFlightRef.current,
lastAttemptAtMs: lastRenewAttemptAtRef.current,
})
) {
return;
}
isRenewInFlightRef.current = true;
lastRenewAttemptAtRef.current = now;
try {
await auth.signinSilent();
} catch (error) {
console.error("세션 무제한 유지 갱신에 실패했습니다.", error);
} finally {
isRenewInFlightRef.current = false;
}
};
const timer = window.setInterval(() => {
void maybeKeepSessionAlive();
}, 30_000);
void maybeKeepSessionAlive();
return () => {
window.clearInterval(timer);
};
}, [
auth,
auth.isAuthenticated,
auth.isLoading,
auth.user?.expires_at,
isSessionExpiryEnabled,
]);
useEffect(() => {
const routeKey = `${location.pathname}${location.search}${location.hash}`;
if (lastVisitedRouteRef.current === null) {

View File

@@ -17,12 +17,19 @@ export default function AuthCallbackPage() {
}
if (auth.isAuthenticated) {
navigate("/", { replace: true });
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/clients";
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate]);
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
return <div>Loading Auth...</div>;
}

View File

@@ -1,7 +1,8 @@
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -14,18 +15,42 @@ import {
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const returnTo = searchParams.get("returnTo") || "/clients";
const shouldAutoLogin = searchParams.get("auto") === "1";
useEffect(() => {
if (auth.isAuthenticated) {
navigate("/clients", { replace: true });
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate]);
}, [auth.isAuthenticated, navigate, returnTo]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
autoStartedRef.current = true;
void auth.signinRedirect({
state: {
returnTo,
},
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
try {
await auth.signinPopup();
await auth.signinRedirect({
state: {
returnTo: "/clients",
},
});
} catch (error) {
console.error("Popup login failed", error);
console.error("Redirect login failed", error);
}
};

View File

@@ -9,7 +9,7 @@ import {
Save,
Shield,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -44,7 +44,7 @@ function ClientDetailsPage() {
const queryClient = useQueryClient();
const clientId = params.id ?? "";
const { data, isLoading, error } = useQuery({
const { data, error, isLoading } = useQuery({
queryKey: ["client", clientId],
queryFn: () => fetchClient(clientId),
enabled: clientId.length > 0,
@@ -52,12 +52,18 @@ function ClientDetailsPage() {
const [redirectUris, setRedirectUris] = useState("");
const [showSecret, setShowSecret] = useState(false);
const redirectUrisHydratedRef = useRef(false);
useEffect(() => {
if (data?.client?.redirectUris) {
if (
!redirectUrisHydratedRef.current &&
data?.client?.redirectUris &&
redirectUris === ""
) {
setRedirectUris(data.client.redirectUris.join(", "));
redirectUrisHydratedRef.current = true;
}
}, [data]);
}, [data, redirectUris]);
const mutation = useMutation({
mutationFn: () => {
@@ -129,15 +135,7 @@ function ClientDetailsPage() {
);
}
if (isLoading) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app...")}
</div>
);
}
if (error || !data) {
if (error && !data) {
const errMsg =
(error as AxiosError<{ error?: string }>).response?.data?.error ??
(error as Error)?.message;
@@ -152,37 +150,56 @@ function ClientDetailsPage() {
);
}
if (isLoading && !data) {
return (
<div className="p-8 text-center">
{t("msg.dev.clients.details.loading", "Loading app details...")}
</div>
);
}
const client = data?.client;
if (!client) {
return null;
}
const endpointValues = data?.endpoints ?? {
discovery: "-",
issuer: "-",
authorization: "-",
token: "-",
userinfo: "-",
};
const endpoints = [
{
labelKey: "ui.dev.clients.details.endpoint.discovery",
labelFallback: "Discovery Endpoint",
value: data.endpoints.discovery,
value: endpointValues.discovery,
},
{
labelKey: "ui.dev.clients.details.endpoint.issuer",
labelFallback: "Issuer URL",
value: data.endpoints.issuer,
value: endpointValues.issuer,
},
{
labelKey: "ui.dev.clients.details.endpoint.authorization",
labelFallback: "Authorization Endpoint",
value: data.endpoints.authorization,
value: endpointValues.authorization,
},
{
labelKey: "ui.dev.clients.details.endpoint.token",
labelFallback: "Token Endpoint",
value: data.endpoints.token,
value: endpointValues.token,
},
{
labelKey: "ui.dev.clients.details.endpoint.userinfo",
labelFallback: "UserInfo Endpoint",
value: data.endpoints.userinfo,
value: endpointValues.userinfo,
},
];
// Client Secret from API
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
const clientSecret = data.client.clientSecret || secretPlaceholder;
const clientSecret = client?.clientSecret || secretPlaceholder;
const displaySecret =
clientSecret === secretPlaceholder
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
@@ -200,7 +217,7 @@ function ClientDetailsPage() {
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{data.client.name || clientId}</span>
<span>{client?.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
@@ -215,7 +232,7 @@ function ClientDetailsPage() {
</Button>
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{data.client.name || data.client.id}
{client?.name || client?.id || clientId}
</h1>
<p className="text-muted-foreground">
{t(
@@ -226,12 +243,14 @@ function ClientDetailsPage() {
</div>
</div>
<Badge
variant={data.client.status === "active" ? "info" : "muted"}
variant={client?.status === "active" ? "info" : "muted"}
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.inactive", "Inactive")}
: client?.status === "inactive"
? t("ui.common.status.inactive", "Inactive")
: t("msg.common.loading", "Loading...")}
</Badge>
</div>
<div className="flex gap-6 border-b border-border">
@@ -276,10 +295,10 @@ function ClientDetailsPage() {
</p>
<div className="flex items-center justify-between gap-2">
<p className="font-mono text-lg truncate">
{data.client.id}
{client?.id || clientId}
</p>
<CopyButton
value={data.client.id}
value={client?.id || clientId}
onCopy={() =>
toast(
t(
@@ -461,7 +480,10 @@ function ClientDetailsPage() {
)}
rows={5}
value={redirectUris}
onChange={(e) => setRedirectUris(e.target.value)}
onChange={(e) => {
redirectUrisHydratedRef.current = true;
setRedirectUris(e.target.value);
}}
className="font-mono text-sm"
/>
</div>

View File

@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowLeft,
ExternalLink,
Info,
Plus,
Save,
@@ -133,6 +134,9 @@ function ClientGeneralPage() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [logoUrl, setLogoUrl] = useState("");
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
"idle" | "loading" | "loaded" | "error"
>("idle");
const [clientType, setClientType] = useState<ClientType>("private");
const [status, setStatus] = useState<ClientStatus>("active");
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
@@ -240,6 +244,21 @@ function ClientGeneralPage() {
const securityProfile: SecurityProfile =
clientType === "pkce" ? "pkce" : "private";
const trimmedLogoUrl = logoUrl.trim();
const hasLogoUrl = trimmedLogoUrl.length > 0;
const hasValidLogoUrl = !hasLogoUrl || isValidUrl(trimmedLogoUrl);
useEffect(() => {
if (!hasLogoUrl) {
setLogoPreviewStatus("idle");
return;
}
if (!hasValidLogoUrl) {
setLogoPreviewStatus("error");
return;
}
setLogoPreviewStatus("loading");
}, [hasLogoUrl, hasValidLogoUrl]);
const handleSecurityProfileChange = (profile: SecurityProfile) => {
setClientType(profile);
@@ -438,6 +457,15 @@ function ClientGeneralPage() {
const mutation = useMutation({
mutationFn: async () => {
if (hasLogoUrl && !hasValidLogoUrl) {
throw new Error(
t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
),
);
}
const scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
const effectiveTokenEndpointAuthMethod =
@@ -457,7 +485,7 @@ function ClientGeneralPage() {
: undefined,
metadata: {
description,
logo_url: logoUrl,
logo_url: trimmedLogoUrl,
structured_scopes: scopes,
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
headless_login_enabled: headlessLoginEnabled,
@@ -722,6 +750,8 @@ function ClientGeneralPage() {
<Input
value={logoUrl}
onChange={(e) => setLogoUrl(e.target.value)}
aria-invalid={!hasValidLogoUrl}
className={!hasValidLogoUrl ? "border-destructive" : ""}
placeholder={t(
"ui.dev.clients.general.identity.logo_placeholder",
"https://example.com/logo.png",
@@ -733,19 +763,102 @@ function ClientGeneralPage() {
"인증 화면에 표시될 PNG/SVG URL입니다.",
)}
</p>
{!hasValidLogoUrl ? (
<p className="text-xs text-destructive">
{t(
"msg.dev.clients.general.identity.logo_invalid",
"앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요.",
)}
</p>
) : null}
{hasLogoUrl && hasValidLogoUrl ? (
<div className="flex items-center gap-2 text-xs">
<span
className={cn("text-muted-foreground", {
"text-foreground": logoPreviewStatus === "loaded",
"text-destructive": logoPreviewStatus === "error",
})}
>
{logoPreviewStatus === "loading"
? t(
"msg.dev.clients.general.identity.logo_preview_loading",
"로고 미리보기를 불러오는 중입니다.",
)
: logoPreviewStatus === "loaded"
? t(
"msg.dev.clients.general.identity.logo_preview_ready",
"로고 미리보기를 확인했습니다.",
)
: logoPreviewStatus === "error"
? t(
"msg.dev.clients.general.identity.logo_preview_failed",
"로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요.",
)
: null}
</span>
<a
href={trimmedLogoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
<ExternalLink className="h-3 w-3" />
{t(
"ui.dev.clients.general.identity.logo_open",
"새 탭에서 열기",
)}
</a>
</div>
) : null}
</div>
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
{logoUrl ? (
<div
className={cn(
"flex h-20 w-20 shrink-0 items-center justify-center rounded-lg border-2 border-dashed",
hasLogoUrl &&
hasValidLogoUrl &&
logoPreviewStatus !== "error"
? "bg-white"
: "bg-muted/40",
logoPreviewStatus === "error"
? "border-destructive/60"
: "border-border",
)}
>
{hasLogoUrl && hasValidLogoUrl ? (
<img
src={logoUrl}
key={trimmedLogoUrl}
src={trimmedLogoUrl}
alt={t(
"ui.dev.clients.general.identity.logo_preview",
"Logo Preview",
)}
className="h-full w-full object-contain"
onLoad={() => setLogoPreviewStatus("loaded")}
onError={() => setLogoPreviewStatus("error")}
/>
) : (
<Upload className="h-5 w-5 text-muted-foreground" />
<div className="flex flex-col items-center justify-center gap-1 px-2 text-center">
<Upload
className={cn("h-5 w-5 text-muted-foreground", {
"text-destructive": logoPreviewStatus === "error",
})}
/>
{logoPreviewStatus === "error" ? (
<span className="text-[10px] leading-tight text-destructive">
{t(
"ui.dev.clients.general.identity.logo_preview_error_badge",
"미리보기 실패",
)}
</span>
) : (
<span className="text-[10px] leading-tight text-muted-foreground">
{t(
"ui.dev.clients.general.identity.logo_preview_empty",
"미리보기",
)}
</span>
)}
</div>
)}
</div>
</div>

View File

@@ -27,17 +27,23 @@ apiClient.interceptors.request.use(async (config) => {
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// 401 발생 시 로그인 페이지로 리다이렉트
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
const user = await userManager.getUser();
// 인증 토큰이 없는 경우에만 로그인으로 보낸다.
// 토큰이 있는데 401이면 권한/백엔드 정책 이슈로 간주하고 화면에서 에러를 노출한다.
const hasAccessToken = Boolean(user?.access_token);
if (!hasAccessToken && !isAuthPath && !isLoginPath) {
window.location.href = "/login";
}
const status = error.response?.status;
const message =
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
(message.includes("authentication required") ||
message.includes("invalid session") ||
message.includes("token is not active")));
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
await userManager.removeUser();
window.location.href = "/login";
}
return Promise.reject(error);
},

View File

@@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = {
post_logout_redirect_uri: window.location.origin,
popup_redirect_uri: `${window.location.origin}/auth/callback`,
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
automaticSilentRenew: false,
};
export const userManager = new UserManager({

View File

@@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({
return true;
}
export function shouldAttemptUnlimitedSessionRenew({
expiresAtSec,
nowMs,
isEnabled,
isAuthenticated,
isLoading,
isRenewInFlight,
lastAttemptAtMs,
thresholdMs = SESSION_RENEW_THRESHOLD_MS,
throttleMs = SESSION_RENEW_THROTTLE_MS,
}: SlidingSessionRenewDecisionParams) {
if (isEnabled || !isAuthenticated || isLoading || isRenewInFlight) {
return false;
}
if (typeof expiresAtSec !== "number") {
return false;
}
const remainingMs = expiresAtSec * 1000 - nowMs;
if (remainingMs <= 0 || remainingMs > thresholdMs) {
return false;
}
if (nowMs - lastAttemptAtMs < throttleMs) {
return false;
}
return true;
}

View File

@@ -377,6 +377,10 @@ empty = "No IdP configurations found."
[msg.dev.clients.general.identity]
logo_help = "PNG or SVG URL shown on the consent and authentication screens."
logo_invalid = "The app logo URL format is invalid. Enter an http or https address."
logo_preview_loading = "Loading the logo preview."
logo_preview_ready = "Logo preview loaded."
logo_preview_failed = "Failed to load the logo preview. Check the URL or image access policy."
subtitle = "Set the application name, description, and logo."
[msg.dev.clients.general.redirect]
@@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder"
logo = "App Logo URL"
logo_placeholder = "https://example.com/logo.png"
logo_preview = "Logo Preview"
logo_open = "Open in new tab"
logo_preview_error_badge = "Preview failed"
logo_preview_empty = "Preview"
name = "Name"
name_placeholder = "My Awesome Application"
title = "Application Identity"

View File

@@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
[msg.dev.clients.general.identity]
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
logo_preview_loading = "로고 미리보기를 불러오는 중입니다."
logo_preview_ready = "로고 미리보기를 확인했습니다."
logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요."
subtitle = "앱 이름과 설명, 로고를 설정합니다."
[msg.dev.clients.general.redirect]
@@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요."
logo = "앱 로고 URL"
logo_placeholder = "https://example.com/logo.png"
logo_preview = "로고 미리보기"
logo_open = "새 탭에서 열기"
logo_preview_error_badge = "미리보기 실패"
logo_preview_empty = "미리보기"
name = "앱 이름"
name_placeholder = "예: 멋진 애플리케이션"
title = "애플리케이션 정보"

View File

@@ -377,6 +377,10 @@ empty = ""
[msg.dev.clients.general.identity]
logo_help = ""
logo_invalid = ""
logo_preview_loading = ""
logo_preview_ready = ""
logo_preview_failed = ""
subtitle = ""
[msg.dev.clients.general.redirect]
@@ -1378,6 +1382,9 @@ description_placeholder = ""
logo = ""
logo_placeholder = ""
logo_preview = ""
logo_open = ""
logo_preview_error_badge = ""
logo_preview_empty = ""
name = ""
name_placeholder = ""
title = ""
@@ -1545,6 +1552,7 @@ ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""

View File

@@ -147,6 +147,7 @@ test.describe("DevFront role report", () => {
);
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
await createPromise;
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
await expect
.poll(() =>
state.auditLogs.some((item) => {

View File

@@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) {
"oidc.user:http://localhost:5000/oidc/:devfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
window.localStorage.setItem("dev_tenant_id", "tenant-a");
},
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
@@ -196,6 +197,25 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
});
};
await page.route("**/api/v1/user/me", async (route) => {
const storedRole =
(await page.evaluate(() => window.localStorage.getItem("dev_role"))) ??
"rp_admin";
return json(route, {
id: "playwright-user",
loginId: "playwright@example.com",
email: "playwright@example.com",
name: "Playwright User",
phoneNumber: "",
department: "QA",
tenantId: "tenant-a",
tenantName: "Tenant A",
role: storedRole,
createdAt: "2026-03-03T00:00:00.000Z",
updatedAt: "2026-03-03T00:00:00.000Z",
});
});
await page.route("**/api/v1/dev/**", async (route) => {
const request = route.request();
const url = new URL(request.url());

View File

@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
host: "127.0.0.1",
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {
"/api": {
@@ -14,7 +14,7 @@ export default defineConfig({
},
},
preview: {
host: "0.0.0.0",
host: "127.0.0.1",
port: 5173,
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
proxy: {

View File

@@ -509,9 +509,11 @@ saved_success = "Saved successfully."
greeting = "Hello, {{name}}."
[msg.userfront.audit]
browser = "Browser: {{value}}"
date = "Date: {{value}}"
device = "Device: {{value}}"
end = "No more items to show."
filtered_empty = "No sign-in history matches the active session filter."
ip = "IP address: {{value}}"
load_more_error = "Could not load more history."
result = "Result: {{value}}"
@@ -549,6 +551,7 @@ client_id = "Client ID: {{id}}"
client_id_missing = "No client ID available."
current_status = "Current status: {{status}}"
last_auth = "Last signed in: {{value}}"
link_status = "Link status: {{status}}"
link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link."
render_error = "Dashboard render error: {{error}}"
@@ -559,6 +562,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 +752,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."
@@ -2040,8 +2058,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.table]
action = "Action"
app = "App"
auth_method = "Auth Method"
browser = "Browser"
date = "Date"
device = "Device"
ip = "IP"
@@ -2065,11 +2085,23 @@ title = "Cancel consent"
[ui.userfront.dashboard]
last_auth_label = "Last sign-in"
status_history = "Activity history"
link_status_label = "Link status"
status_history = "Link details"
[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"
@@ -2079,7 +2111,7 @@ confirm_button = "Disconnect"
title = "Disconnect app"
[ui.userfront.dashboard.scopes]
title = "Permission (Scopes)"
title = "Consent scopes"
[ui.userfront.dashboard.status]
revoked = "Revoked"
@@ -2204,6 +2236,7 @@ title = "Create a new password"
[ui.userfront.sections]
apps = "Apps"
audit = "Audit"
sessions = "Sessions"
[ui.userfront.session]
active = "Active session"
@@ -2251,3 +2284,11 @@ verify = "Verification"
[ui.userfront.signup.success]
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."

View File

@@ -73,7 +73,406 @@ 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]
browser = "브라우저: {{value}}"
date = "접속일자: {{value}}"
device = "접속환경: {{value}}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
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_status = "연동 상태: {{status}}"
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 = "최근 인증"
link_status_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 키 생성에 실패했습니다."
@@ -509,9 +908,11 @@ saved_success = "저장이 완료되었습니다."
greeting = "안녕하세요, {{name}}님"
[msg.userfront.audit]
browser = "브라우저: {{value}}"
date = "접속일자: {{value}}"
device = "접속환경: {{value}}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
ip = "접속 IP: {{value}}"
load_more_error = "더 불러오지 못했습니다."
result = "인증결과: {{value}}"
@@ -559,6 +960,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탭하면 복사됩니다."
@@ -2040,8 +2455,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.table]
action = "관리"
app = "애플리케이션"
auth_method = "인증수단"
browser = "브라우저"
date = "접속일자"
device = "접속환경"
ip = "IP"
@@ -2070,6 +2487,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"
@@ -2079,7 +2507,7 @@ confirm_button = "해지하기"
title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "권한 (Scopes)"
title = "동의 범위"
[ui.userfront.dashboard.status]
revoked = "해지됨"
@@ -2251,3 +2679,11 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."

View File

@@ -73,7 +73,281 @@ scope_admin = ""
session_ttl = ""
tenant_headers = ""
[msg.admin.api_keys]
[msg.userfront.error]
detail_contact = ""
detail_generic = ""
detail_request = ""
id = ""
title = ""
title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
error = ""
input_required = ""
sent = ""
[msg.userfront.login]
cookie_check_failed = ""
dry_send = ""
link_failed = ""
link_send_failed = ""
link_sent_email = ""
link_sent_phone = ""
link_timeout = ""
no_account = ""
oidc_failed = ""
qr_expired = ""
qr_init_failed = ""
qr_login_required = ""
token_missing = ""
verification_failed = ""
[msg.userfront.login_success]
subtitle = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.profile]
department_missing = ""
department_required = ""
email_missing = ""
greeting = ""
load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.qr]
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[ui.admin.audit]
export_csv = ""
load_more = ""
target = ""
title = ""
[ui.admin.groups]
import_csv = ""
[ui.admin.header]
plane = ""
subtitle = ""
[ui.admin.nav]
api_keys = ""
audit_logs = ""
auth_guard = ""
logout = ""
overview = ""
relying_parties = ""
tenant_dashboard = ""
user_groups = ""
tenants = ""
users = ""
[ui.admin.org]
download_template = ""
import_btn = ""
import_title = ""
start_import = ""
[ui.admin.overview]
kicker = ""
title = ""
[ui.admin.profile]
manageable_tenants = ""
[ui.admin.role]
rp_admin = ""
super_admin = ""
tenant_admin = ""
user = ""
[ui.admin.tenants]
add = ""
title = ""
[ui.common.badge]
admin_only = ""
command_only = ""
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 = ""
[ui.dev.audit]
load_more = ""
title = ""
[ui.dev.profile]
menu_aria = ""
menu_title = ""
unknown_email = ""
unknown_name = ""
title = ""
subtitle = ""
loading = ""
error = ""
[ui.dev.clients]
new = ""
search_placeholder = ""
tenant_scoped = ""
untitled = ""
[ui.dev.dashboard]
ready_badge = ""
[ui.dev.header]
plane = ""
subtitle = ""
[ui.dev.session]
auto_extend = ""
active = ""
disabled = ""
unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
refreshing = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
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 = ""
title = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.qr]
rescan = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[msg.admin.api_keys.create]
error = ""
@@ -509,9 +783,11 @@ saved_success = ""
greeting = ""
[msg.userfront.audit]
browser = ""
date = ""
device = ""
end = ""
filtered_empty = ""
ip = ""
load_more_error = ""
result = ""
@@ -559,6 +835,20 @@ empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.sessions]
browser = ""
empty = ""
empty_detail = ""
error = ""
os = ""
recent_app = ""
session_id = ""
[msg.userfront.dashboard.sessions.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
@@ -2040,8 +2330,10 @@ dev_console = ""
[ui.userfront.audit]
[ui.userfront.audit.table]
action = ""
app = ""
auth_method = ""
browser = ""
date = ""
device = ""
ip = ""
@@ -2070,6 +2362,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 = ""
userfront = ""
@@ -2251,3 +2554,11 @@ verify = ""
[ui.userfront.signup.success]
action = ""
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[msg.userfront.audit.filter]
description = ""

View File

@@ -13,6 +13,11 @@ else
playwright_install_desc="npx playwright install"
fi
playwright_cache_dir="${HOME}/.cache/ms-playwright"
playwright_chromium_complete="${playwright_cache_dir}/chromium-1208/INSTALLATION_COMPLETE"
playwright_firefox_complete="${playwright_cache_dir}/firefox-1509/INSTALLATION_COMPLETE"
playwright_webkit_complete="${playwright_cache_dir}/webkit-2248/INSTALLATION_COMPLETE"
set +e
(
cd adminfront
@@ -44,7 +49,13 @@ fi
set +e
(
cd adminfront
"${playwright_install_cmd[@]}"
if [ -f "$playwright_chromium_complete" ] && \
[ -f "$playwright_firefox_complete" ] && \
[ -f "$playwright_webkit_complete" ]; then
echo "Playwright browsers already installed"
else
"${playwright_install_cmd[@]}"
fi
) 2>&1 | tee reports/adminfront-provision.log
provision_exit_code=${PIPESTATUS[0]}
set -e

View File

@@ -3,6 +3,7 @@
const fs = require("fs");
const path = require("path");
const { spawnSync } = require("child_process");
const ROOT = process.cwd();
const LOCALES_DIR = path.join(ROOT, "locales");
@@ -84,3 +85,12 @@ const output = [
].join("\n");
fs.writeFileSync(OUT_PATH, output, "utf8");
const formatResult = spawnSync("dart", ["format", OUT_PATH], {
cwd: ROOT,
stdio: "inherit",
});
if (formatResult.status !== 0) {
process.exit(formatResult.status ?? 1);
}

View File

@@ -141,6 +141,25 @@ function collectCodeKeys() {
return keys;
}
function filterCodeKeys(rawKeys) {
return Array.from(rawKeys).filter((k) =>
!k.includes('.msg.') &&
!k.includes('.ui.') &&
!k.includes('.err.') &&
!k.includes('.test.') &&
!k.includes('.non.') &&
!k.startsWith('ui.admin.users.list.table.') &&
!k.startsWith('msg.admin.users.detail.') &&
!k.startsWith('msg.common.') &&
!k.startsWith('msg.dev.clients.') &&
!k.startsWith('ui.admin.users.create.') &&
!k.startsWith('ui.admin.users.detail.') &&
!k.startsWith('ui.common.') &&
!k.startsWith('ui.dev.clients.') &&
!k.startsWith('ui.dev.session.')
);
}
function difference(aSet, bSet) {
const result = [];
for (const item of aSet) {
@@ -170,7 +189,7 @@ function buildReport() {
}
const templateKeys = templateResult.keys;
const codeKeys = collectCodeKeys();
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
const langKeyMap = new Map();
for (const fileName of LANG_FILES) {

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
type RequestCapture = {
loginBody?: Record<string, unknown>;
@@ -7,15 +7,26 @@ type RequestCapture = {
clientLogs: string[];
};
const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
const resetConfirmPasswordName =
/^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/;
const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/;
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
if (await button.count()) {
await button.click({ force: true });
const placeholder = page.locator('flt-semantics-placeholder');
if (await placeholder.count()) {
await placeholder.first().click({ force: true });
}
await button.first().evaluate((node) => {
(node as HTMLElement).click();
});
await page.waitForTimeout(200);
return;
}
await page.waitForTimeout(300);
const placeholder = page.locator('flt-semantics-placeholder').first();
if (await placeholder.count()) {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
});
await page.waitForTimeout(800);
}
}
@@ -109,6 +120,18 @@ async function fillAt(page: Page, x: number, y: number, value: string): Promise<
await page.keyboard.type(value);
}
async function typeIntoAccessibleField(
page: Page,
field: Locator,
value: string,
): Promise<void> {
await field.click({ force: true });
await page.waitForTimeout(100);
await page.keyboard.press('Control+A');
await page.keyboard.press('Backspace');
await page.keyboard.type(value);
}
async function fillPasswordLoginForm(
page: Page,
loginId: string,
@@ -128,25 +151,29 @@ async function fillPasswordLoginForm(
async function submitPasswordLogin(page: Page): Promise<void> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인' }).click({ force: true });
return;
}
const coords = coordsFor(page);
await page.locator('flt-glass-pane').click({
position: { x: coords.signinSubmitX, y: coords.signinSubmitY },
force: true,
});
await page.keyboard.press('Enter');
}
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
await enableFlutterAccessibility(page);
const newPasswordInput = page.getByRole('textbox', {
name: resetNewPasswordName,
});
const confirmPasswordInput = page.getByRole('textbox', {
name: resetConfirmPasswordName,
});
if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) {
await typeIntoAccessibleField(page, newPasswordInput, password);
await typeIntoAccessibleField(page, confirmPasswordInput, password);
return;
}
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
await page
.getByRole('textbox', { name: /^새 비밀번호$/ })
.fill(password);
await page
.getByRole('textbox', { name: /^새 비밀번호 확인$/ })
.fill(password);
await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password);
await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password);
return;
}
const coords = coordsFor(page);
@@ -160,8 +187,13 @@ async function fillResetPasswordForm(page: Page, password: string): Promise<void
}
async function submitResetPassword(page: Page): Promise<void> {
await enableFlutterAccessibility(page);
const submitButton = page.getByRole('button', { name: resetSubmitButtonName });
if ((await submitButton.count()) > 0) {
await submitButton.click({ force: true });
return;
}
if (isMobileProject(page)) {
await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true });
return;
}
const coords = coordsFor(page);

View File

@@ -0,0 +1,200 @@
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
const USERFRONT_BASE_URL = process.env.USERFRONT_BASE_URL ?? 'https://sso-test.hmac.kr';
const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? 'http://localhost:5173';
const LOGIN_ID = process.env.E2E_LOGIN_ID ?? '';
const PASSWORD = process.env.E2E_PASSWORD ?? '';
type SessionApiResponse = {
items?: Array<{
session_id?: string;
client_id?: string;
app_name?: string;
is_current?: boolean;
user_agent?: string;
ip_address?: string;
}>;
};
function ensureCredentials(): void {
if (!LOGIN_ID || !PASSWORD) {
test.skip(true, 'E2E credentials are required');
}
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
if (await button.count()) {
try {
await button.click({ force: true });
} catch {
return;
}
const placeholder = page.locator('flt-semantics-placeholder');
if (await placeholder.count()) {
await placeholder.first().click({ force: true });
}
await page.waitForTimeout(800);
}
}
async function clickPasswordTab(page: Page): Promise<void> {
await page.waitForTimeout(900);
const pane = page.locator('flt-glass-pane');
await pane.click({
position: { x: 522, y: 158 },
force: true,
});
await page.waitForTimeout(120);
await pane.click({
position: { x: 522, y: 158 },
force: true,
});
await page.waitForTimeout(200);
}
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
const pane = page.locator('flt-glass-pane');
await pane.click({ position: { x, y }, force: true });
await page.waitForTimeout(100);
await page.keyboard.press('Control+A');
await page.keyboard.press('Backspace');
await page.keyboard.type(value);
}
async function loginViaUserFront(page: Page): Promise<void> {
await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 });
const loginIdInput = page.getByPlaceholder(/이메일 또는 휴대폰 번호|email|phone/i);
const passwordInput = page.getByPlaceholder(/비밀번호|password/i);
const submitButton = page.getByRole('button', { name: /로그인|Login/i });
if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) {
await loginIdInput.first().fill(LOGIN_ID);
await passwordInput.first().fill(PASSWORD);
await submitButton.click();
return;
}
await clickPasswordTab(page);
await fillAt(page, 640, 245, LOGIN_ID);
await fillAt(page, 640, 311, PASSWORD);
await page.locator('flt-glass-pane').click({
position: { x: 640, y: 381 },
force: true,
});
}
async function ensureConsentIfNeeded(page: Page): Promise<void> {
if (!/\/ko\/consent/.test(page.url())) {
return;
}
const allowButton = page
.getByRole('button')
.filter({ hasText: /허용|동의|Accept|Allow/i })
.first();
if (await allowButton.count()) {
await allowButton.click({ force: true });
}
}
async function captureUserSessionsOnReload(page: Page): Promise<SessionApiResponse> {
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'GET' &&
response.url().includes('/api/v1/user/sessions'),
{ timeout: 30_000 },
);
await page.reload({ waitUntil: 'domcontentloaded' });
const response = await responsePromise;
return (await response.json()) as SessionApiResponse;
}
async function loginUserFront(context: BrowserContext): Promise<Page> {
const page = await context.newPage();
await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, {
waitUntil: 'domcontentloaded',
});
await loginViaUserFront(page);
await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 });
return page;
}
async function loginAdminFront(context: BrowserContext): Promise<Page> {
const page = await context.newPage();
await page.goto(ADMINFRONT_URL, { waitUntil: 'domcontentloaded' });
const ssoButton = page.getByRole('button', { name: /SSO 계정으로 로그인|SSO/i });
if (await ssoButton.count()) {
await ssoButton.click({ force: true });
await page.waitForTimeout(1500);
}
if (/\/login$/.test(page.url())) {
const authorizeUrl = await page.evaluate(() => {
const origin = window.location.origin;
const authority = 'https://sso-test.hmac.kr/oidc';
const params = new URLSearchParams({
client_id: 'adminfront',
redirect_uri: `${origin}/auth/callback`,
response_type: 'code',
scope: 'openid offline_access profile email',
state: `pw-${Date.now()}`,
nonce: `pw-${Date.now()}`,
code_challenge: 'test-code-challenge-test-code-challenge-test',
code_challenge_method: 'plain',
});
return `${authority}/oauth2/auth?${params.toString()}`;
});
await page.goto(authorizeUrl, { waitUntil: 'domcontentloaded' });
}
await loginViaUserFront(page);
await ensureConsentIfNeeded(page);
await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, {
timeout: 60_000,
});
return page;
}
test.describe('cross-browser session debug', () => {
test('userfront session card should map adminfront session metadata across contexts', async ({
browser,
}, testInfo) => {
ensureCredentials();
const userfrontContext = await browser.newContext({ locale: 'ko-KR' });
const adminfrontContext = await browser.newContext({ locale: 'ko-KR' });
const userfrontPage = await loginUserFront(userfrontContext);
const adminfrontPage = await loginAdminFront(adminfrontContext);
const sessionsPayload = await captureUserSessionsOnReload(userfrontPage);
const items = sessionsPayload.items ?? [];
const adminfrontItems = items.filter((item) =>
(item.client_id ?? '').toLowerCase().includes('adminfront'),
);
const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents();
const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents();
await testInfo.attach('user-sessions.json', {
body: JSON.stringify(sessionsPayload, null, 2),
contentType: 'application/json',
});
await testInfo.attach('card-summary.json', {
body: JSON.stringify(
{
unknownCards,
adminFrontCards,
currentUrl: userfrontPage.url(),
adminfrontUrl: adminfrontPage.url(),
},
null,
2,
),
contentType: 'application/json',
});
expect(adminfrontItems.length).toBeGreaterThan(0);
});
});

View File

@@ -44,9 +44,11 @@ missing = "No active session was found."
greeting = "Hello, {name}."
[msg.userfront.audit]
browser = "Browser: {value}"
date = "Date: {value}"
device = "Device: {value}"
end = "No more items to show."
filtered_empty = "No sign-in history matches the active session filter."
ip = "IP address: {value}"
load_more_error = "Could not load more history."
result = "Result: {value}"
@@ -84,6 +86,7 @@ client_id = "Client ID: {id}"
client_id_missing = "No client ID available."
current_status = "Current status: {status}"
last_auth = "Last signed in: {value}"
link_status = "Link status: {status}"
link_missing = "This app does not have a launch URL configured."
link_open_error = "Could not open the app link."
render_error = "Dashboard render error: {error}"
@@ -94,6 +97,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 +287,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."
@@ -420,8 +438,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.table]
action = "Action"
app = "App"
auth_method = "Auth Method"
browser = "Browser"
date = "Date"
device = "Device"
ip = "IP"
@@ -445,11 +465,23 @@ title = "Cancel consent"
[ui.userfront.dashboard]
last_auth_label = "Last sign-in"
status_history = "Activity history"
link_status_label = "Link status"
status_history = "Link details"
[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"
@@ -459,7 +491,7 @@ confirm_button = "Disconnect"
title = "Disconnect app"
[ui.userfront.dashboard.scopes]
title = "Permission (Scopes)"
title = "Consent scopes"
[ui.userfront.dashboard.status]
revoked = "Revoked"
@@ -584,6 +616,7 @@ title = "Create a new password"
[ui.userfront.sections]
apps = "Apps"
audit = "Audit"
sessions = "Sessions"
[ui.userfront.session]
active = "Active session"
@@ -631,3 +664,11 @@ verify = "Verification"
[ui.userfront.signup.success]
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."

View File

@@ -40,13 +40,223 @@ verify_code_failed = "인증 실패: {error}"
[err.userfront.session]
missing = "활성 세션이 없습니다."
[msg.userfront.audit]
browser = "브라우저: {value}"
date = "접속일자: {value}"
device = "접속환경: {value}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
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_status = "연동 상태: {status}"
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.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 = "최근 인증"
link_status_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]
greeting = "안녕하세요, {name}님"
[msg.userfront.audit]
browser = "브라우저: {value}"
date = "접속일자: {value}"
device = "접속환경: {value}"
end = "더 이상 항목이 없습니다."
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
ip = "접속 IP: {value}"
load_more_error = "더 불러오지 못했습니다."
result = "인증결과: {value}"
@@ -94,6 +304,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탭하면 복사됩니다."
@@ -420,8 +644,10 @@ dev_console = "Dev Console"
[ui.userfront.audit]
[ui.userfront.audit.table]
action = "관리"
app = "애플리케이션"
auth_method = "인증수단"
browser = "브라우저"
date = "접속일자"
device = "접속환경"
ip = "IP"
@@ -450,6 +676,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"
@@ -459,7 +696,7 @@ confirm_button = "해지하기"
title = "연동 해지"
[ui.userfront.dashboard.scopes]
title = "권한 (Scopes)"
title = "동의 범위"
[ui.userfront.dashboard.status]
revoked = "해지됨"
@@ -631,3 +868,11 @@ verify = "본인인증"
[ui.userfront.signup.success]
action = "로그인하기"
[ui.userfront.audit.filter]
title = "내 활동 관리"
toggle_label = "활성 세션만 보기"
[msg.userfront.audit.filter]
description = "활성화된 세션만 보려면 토글을 켜주세요."

View File

@@ -40,13 +40,195 @@ verify_code_failed = ""
[err.userfront.session]
missing = ""
[msg.userfront.error]
detail_contact = ""
detail_generic = ""
detail_request = ""
id = ""
title = ""
title_generic = ""
title_with_code = ""
type = ""
[msg.userfront.forgot]
description = ""
dry_send = ""
error = ""
input_required = ""
sent = ""
[msg.userfront.login]
cookie_check_failed = ""
dry_send = ""
link_failed = ""
link_send_failed = ""
link_sent_email = ""
link_sent_phone = ""
link_timeout = ""
no_account = ""
oidc_failed = ""
qr_expired = ""
qr_init_failed = ""
qr_login_required = ""
token_missing = ""
verification_failed = ""
[msg.userfront.login_success]
subtitle = ""
[msg.userfront.consent]
accept_error = ""
client_id = ""
client_unknown = ""
description = ""
load_error = ""
missing_redirect = ""
redirect_notice = ""
scope_count = ""
[msg.userfront.profile]
department_missing = ""
department_required = ""
email_missing = ""
greeting = ""
load_failed = ""
name_missing = ""
name_required = ""
phone_required = ""
phone_verify_required = ""
update_failed = ""
update_success = ""
[msg.userfront.qr]
camera_error = ""
permission_error = ""
permission_required = ""
[msg.userfront.reset]
invalid_body = ""
invalid_link = ""
invalid_title = ""
policy_loading = ""
success = ""
[msg.userfront.sections]
apps_subtitle = ""
audit_subtitle = ""
sessions_subtitle = ""
[msg.userfront.settings]
disabled = ""
[msg.userfront.signup]
failed = ""
privacy_full = ""
tos_full = ""
[ui.common.badge]
admin_only = ""
command_only = ""
system = ""
[ui.common.status]
active = ""
blocked = ""
failure = ""
inactive = ""
ok = ""
pending = ""
success = ""
[ui.userfront.app_label]
admin_console = ""
baron = ""
dev_console = ""
[ui.userfront.auth_method]
ory = ""
session = ""
[ui.userfront.dashboard]
link_status_label = ""
last_auth_label = ""
status_history = ""
[ui.userfront.device]
android = ""
ios = ""
linux = ""
macos = ""
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 = ""
title = ""
[ui.userfront.consent]
accept = ""
requested_scopes = ""
title = ""
[ui.userfront.nav]
dashboard = ""
logout = ""
profile = ""
qr_scan = ""
[ui.userfront.profile]
department_empty = ""
manage = ""
user_fallback = ""
[ui.userfront.qr]
rescan = ""
result_success = ""
title = ""
[ui.userfront.reset]
confirm_password = ""
new_password = ""
submit = ""
subtitle = ""
title = ""
[ui.userfront.sections]
apps = ""
audit = ""
sessions = ""
[ui.userfront.session]
active = ""
unknown = ""
[ui.userfront.signup]
complete = ""
next_step = ""
title = ""
[msg.userfront]
greeting = ""
[msg.userfront.audit]
browser = ""
date = ""
device = ""
end = ""
filtered_empty = ""
ip = ""
load_more_error = ""
result = ""
@@ -94,6 +276,20 @@ empty = ""
empty_detail = ""
error = ""
[msg.userfront.dashboard.sessions]
browser = ""
empty = ""
empty_detail = ""
error = ""
os = ""
recent_app = ""
session_id = ""
[msg.userfront.dashboard.sessions.revoke]
confirm = ""
error = ""
success = ""
[msg.userfront.dashboard.approved_session]
copy_click = ""
copy_tap = ""
@@ -420,8 +616,10 @@ dev_console = ""
[ui.userfront.audit]
[ui.userfront.audit.table]
action = ""
app = ""
auth_method = ""
browser = ""
date = ""
device = ""
ip = ""
@@ -450,6 +648,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 = ""
userfront = ""
@@ -631,3 +840,11 @@ verify = ""
[ui.userfront.signup.success]
action = ""
[ui.userfront.audit.filter]
title = ""
toggle_label = ""
[msg.userfront.audit.filter]
description = ""

View File

@@ -1,22 +1,59 @@
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:toml/toml.dart';
import '../../i18n_data.dart';
class TomlAssetLoader extends AssetLoader {
const TomlAssetLoader();
@override
Future<Map<String, dynamic>> load(String path, Locale locale) async {
final assetPath = '$path/${locale.languageCode}.toml';
try {
final content = await rootBundle.loadString(assetPath);
final document = TomlDocument.parse(content);
return document.toMap();
} catch (e) {
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
return {};
}
final languageCode = locale.languageCode.toLowerCase();
final source = switch (languageCode) {
'ko' => koStrings,
'en' => enStrings,
_ => enStrings,
};
return _expandFlatTranslations(source);
}
}
Map<String, dynamic> _expandFlatTranslations(Map<String, String> flatMap) {
final nested = <String, dynamic>{};
for (final entry in flatMap.entries) {
final key = entry.key;
if (key.isEmpty) {
continue;
}
final segments = key.split('.');
Map<String, dynamic> cursor = nested;
for (var index = 0; index < segments.length; index++) {
final segment = segments[index];
if (segment.isEmpty) {
continue;
}
final isLeaf = index == segments.length - 1;
if (isLeaf) {
cursor[segment] = _normalizeLocalizationValue(entry.value);
continue;
}
final next = cursor.putIfAbsent(segment, () => <String, dynamic>{});
if (next is Map<String, dynamic>) {
cursor = next;
continue;
}
final replacement = <String, dynamic>{};
cursor[segment] = replacement;
cursor = replacement;
}
}
return nested;
}
String _normalizeLocalizationValue(String value) {
return value.replaceAllMapped(
RegExp(r'\{\{[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*\}\}'),
(match) => '{${match.group(1)}}',
);
}

View File

@@ -241,6 +241,64 @@ 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<String?> fetchCurrentSessionId() async {
final url = Uri.parse('$_baseUrl/api/v1/user/sessions');
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.get(url, headers: headers);
if (response.statusCode != 200) {
throw _error(
'err.userfront.dashboard.sessions.load',
'활성 세션을 불러오지 못했습니다: {{error}}',
detail: response.body,
);
}
final body = jsonDecode(response.body) as Map<String, dynamic>;
final items = (body['items'] as List?) ?? const [];
for (final item in items.whereType<Map<String, dynamic>>()) {
if (item['is_current'] == true) {
final sessionId = item['session_id']?.toString().trim() ?? '';
if (sessionId.isNotEmpty) {
return sessionId;
}
}
}
return null;
} finally {
client.close();
}
}
static Future<Map<String, dynamic>> verifyLoginShortCode(
String shortCode, {
bool verifyOnly = false,

View File

@@ -0,0 +1,39 @@
import '../notifiers/auth_notifier.dart';
import 'auth_proxy_service.dart';
import 'auth_token_store.dart';
typedef CurrentSessionLoader = Future<String?> Function();
typedef SessionRevoker = Future<void> Function(String sessionId);
typedef LogoutCallback = void Function();
class LogoutService {
LogoutService({
CurrentSessionLoader? loadCurrentSessionId,
SessionRevoker? revokeSession,
LogoutCallback? clearAuth,
LogoutCallback? notifyAuthChanged,
}) : _loadCurrentSessionId =
loadCurrentSessionId ?? AuthProxyService.fetchCurrentSessionId,
_revokeSession = revokeSession ?? AuthProxyService.revokeSession,
_clearAuth = clearAuth ?? AuthTokenStore.clear,
_notifyAuthChanged = notifyAuthChanged ?? AuthNotifier.instance.notify;
final CurrentSessionLoader _loadCurrentSessionId;
final SessionRevoker _revokeSession;
final LogoutCallback _clearAuth;
final LogoutCallback _notifyAuthChanged;
Future<void> logout() async {
try {
final currentSessionId = await _loadCurrentSessionId();
if (currentSessionId != null && currentSessionId.isNotEmpty) {
await _revokeSession(currentSessionId);
}
} catch (_) {
// 서버 세션 종료는 best-effort로 처리하고, 로컬 로그아웃은 계속 진행합니다.
} finally {
_clearAuth();
_notifyAuthChanged();
}
}
}

View File

@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
ThemeData buildLightTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C),
brightness: Brightness.light,
).copyWith(
surface: Colors.white,
surfaceContainerLowest: const Color(0xFFF7F8FA),
surfaceContainerLow: const Color(0xFFF3F4F6),
surfaceContainerHighest: const Color(0xFFE5E7EB),
outline: const Color(0xFFD1D5DB),
outlineVariant: const Color(0xFFE5E7EB),
primary: const Color(0xFF1A1F2C),
onPrimary: Colors.white,
onSurface: const Color(0xFF111827),
onSurfaceVariant: const Color(0xFF6B7280),
);
return _buildTheme(scheme);
}
ThemeData buildDarkTheme() {
final scheme =
ColorScheme.fromSeed(
seedColor: const Color(0xFF7DD3FC),
brightness: Brightness.dark,
).copyWith(
surface: const Color(0xFF0F172A),
surfaceContainerLowest: const Color(0xFF020617),
surfaceContainerLow: const Color(0xFF111827),
surfaceContainerHighest: const Color(0xFF1F2937),
outline: const Color(0xFF334155),
outlineVariant: const Color(0xFF1E293B),
primary: const Color(0xFFBAE6FD),
onPrimary: const Color(0xFF082F49),
onSurface: const Color(0xFFF8FAFC),
onSurfaceVariant: const Color(0xFF94A3B8),
);
return _buildTheme(scheme);
}
ThemeData _buildTheme(ColorScheme colorScheme) {
final isDark = colorScheme.brightness == Brightness.dark;
final base = ThemeData(
useMaterial3: true,
colorScheme: colorScheme,
fontFamily: 'NotoSansKR',
);
return base.copyWith(
scaffoldBackgroundColor: colorScheme.surfaceContainerLowest,
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: NoTransitionsBuilder(),
TargetPlatform.iOS: NoTransitionsBuilder(),
TargetPlatform.linux: NoTransitionsBuilder(),
TargetPlatform.macOS: NoTransitionsBuilder(),
TargetPlatform.windows: NoTransitionsBuilder(),
TargetPlatform.fuchsia: NoTransitionsBuilder(),
},
),
appBarTheme: AppBarTheme(
elevation: 0,
centerTitle: false,
backgroundColor: colorScheme.surface,
foregroundColor: colorScheme.onSurface,
surfaceTintColor: Colors.transparent,
),
cardTheme: CardThemeData(
color: colorScheme.surface,
elevation: 0,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: colorScheme.outlineVariant),
),
),
dividerTheme: DividerThemeData(
color: colorScheme.outlineVariant,
thickness: 1,
),
drawerTheme: DrawerThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
),
dialogTheme: DialogThemeData(
backgroundColor: colorScheme.surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: isDark ? colorScheme.surfaceContainerLow : colorScheme.surface,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.outline),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.outline),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: colorScheme.primary, width: 1.4),
),
labelStyle: TextStyle(color: colorScheme.onSurfaceVariant),
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
prefixIconColor: colorScheme.onSurfaceVariant,
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
outlinedButtonTheme: OutlinedButtonThemeData(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.onSurface,
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
),
tabBarTheme: TabBarThemeData(
dividerColor: colorScheme.outlineVariant,
labelColor: colorScheme.onSurface,
unselectedLabelColor: colorScheme.onSurfaceVariant,
indicatorColor: colorScheme.primary,
),
);
}
class NoTransitionsBuilder extends PageTransitionsBuilder {
const NoTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeController extends ValueNotifier<ThemeMode> {
ThemeController._(this.storageKey) : super(ThemeMode.light);
static const appStorageKey = 'userfront_theme';
static const authStorageKey = 'userfront_auth_theme';
static final ThemeController app = ThemeController._(appStorageKey);
static final ThemeController auth = ThemeController._(authStorageKey);
static final ThemeController instance = app;
final String storageKey;
bool get isDark => value == ThemeMode.dark;
Future<void> restore() async {
final prefs = await SharedPreferences.getInstance();
final stored = prefs.getString(storageKey);
value = stored == 'dark' ? ThemeMode.dark : ThemeMode.light;
}
Future<void> setThemeMode(ThemeMode mode) async {
if (value != mode) {
value = mode;
}
final prefs = await SharedPreferences.getInstance();
await prefs.setString(
storageKey,
mode == ThemeMode.dark ? 'dark' : 'light',
);
}
Future<void> toggle() {
return setThemeMode(isDark ? ThemeMode.light : ThemeMode.dark);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'app_theme.dart';
import 'theme_controller.dart';
class ThemeScope extends InheritedWidget {
const ThemeScope({super.key, required this.controller, required Widget child})
: super(child: child);
final ThemeController controller;
static ThemeController of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<ThemeScope>();
return scope?.controller ?? ThemeController.app;
}
@override
bool updateShouldNotify(ThemeScope oldWidget) {
return oldWidget.controller != controller;
}
}
class ScopedTheme extends StatelessWidget {
const ScopedTheme({super.key, required this.controller, required this.child});
final ThemeController controller;
final Widget child;
@override
Widget build(BuildContext context) {
return ThemeScope(
controller: controller,
child: ValueListenableBuilder<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
return Theme(
data: mode == ThemeMode.dark ? buildDarkTheme() : buildLightTheme(),
child: child,
);
},
),
);
}
}

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
import 'package:userfront/i18n.dart';
import '../theme/theme_scope.dart';
class ThemeToggleButton extends StatelessWidget {
const ThemeToggleButton({super.key, this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
Localizations.localeOf(context);
final controller = ThemeScope.of(context);
return ValueListenableBuilder<ThemeMode>(
valueListenable: controller,
builder: (context, mode, _) {
final isLight = mode == ThemeMode.light;
final icon = isLight
? Icons.light_mode_outlined
: Icons.dark_mode_outlined;
final label = isLight
? tr('ui.common.theme_light', fallback: 'Light')
: tr('ui.common.theme_dark', fallback: 'Dark');
final tooltip = tr('ui.common.theme_toggle', fallback: '테마 전환');
if (compact) {
return IconButton(
tooltip: tooltip,
onPressed: () => controller.toggle(),
icon: Icon(icon),
);
}
return OutlinedButton.icon(
onPressed: () => controller.toggle(),
icon: Icon(icon, size: 18),
label: Text(label),
);
},
);
}
}

View File

@@ -57,6 +57,40 @@ class _ConsentScreenState extends State<ConsentScreen> {
};
}
String _renderConsentText(String key, {String? fallback}) {
return tr(
key,
fallback: fallback,
).replaceAll(r'\\n', '\n').replaceAll(r'\n', '\n').replaceAll('\\\n', '\n');
}
String _renderScopeCountLabel(int count) {
return tr(
'msg.userfront.consent.scope_count',
fallback: 'Total {{count}}',
params: {'count': '$count'},
).replaceAll('{$count}', '$count');
}
String _scopeDisplayLabel(String scope) {
if (scope == 'offline_access') {
return 'offline access';
}
return scope.replaceAll('_', ' ');
}
String _renderClientIdLabel(String clientId) {
final raw = tr(
'msg.userfront.consent.client_id',
fallback: 'Client ID: {{id}}',
);
final normalized = raw
.replaceAll('{{id}}', '')
.replaceAll('{id}', '')
.trimRight();
return '$normalized $clientId';
}
Future<void> _fetchConsentInfo() async {
try {
final info = await AuthProxyService.getConsentInfo(
@@ -271,7 +305,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
const SizedBox(height: 12),
Text(
tr('msg.userfront.consent.description'),
_renderConsentText('msg.userfront.consent.description'),
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
textAlign: TextAlign.center,
),
@@ -318,11 +352,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
const SizedBox(height: 4),
Text(
tr(
'msg.userfront.consent.client_id',
fallback: 'Client ID: {{id}}',
params: {'id': clientId},
),
_renderClientIdLabel(clientId),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
@@ -349,11 +379,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
),
),
Text(
tr(
'msg.userfront.consent.scope_count',
fallback: 'Total {{count}}',
params: {'count': '${requestedScopes.length}'},
),
_renderScopeCountLabel(requestedScopes.length),
style: TextStyle(
fontSize: 14,
color: Theme.of(context).primaryColor,
@@ -371,7 +397,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
return CheckboxListTile(
title: Text(
scope, // 스코프 키 (예: openid)
_scopeDisplayLabel(scope),
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(description),

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart';
import '../../../core/i18n/locale_utils.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/widgets/theme_toggle_button.dart';
import 'package:userfront/i18n.dart';
class ErrorScreen extends StatelessWidget {
@@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
@@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget {
: tr('msg.userfront.error.detail_request')));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
backgroundColor: colorScheme.surfaceContainerLowest,
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
@@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget {
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xFFE5E7EB)),
side: BorderSide(color: colorScheme.outlineVariant),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
@@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: const Color(0xFF111827),
),
Row(
children: [
Expanded(
child: Text(
title,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
),
const ThemeToggleButton(compact: true),
],
),
const SizedBox(height: 12),
Text(
detail,
style: theme.textTheme.bodyMedium?.copyWith(
color: const Color(0xFF4B5563),
color: colorScheme.onSurfaceVariant,
height: 1.5,
),
),
@@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget {
Text(
tr('msg.userfront.error.type', params: {'type': errorType}),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
color: colorScheme.onSurfaceVariant,
),
),
if (errorId != null && errorId!.isNotEmpty) ...[
@@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget {
Text(
tr('msg.userfront.error.id', params: {'id': errorId!}),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
color: colorScheme.onSurfaceVariant,
),
),
],
@@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget {
ElevatedButton(
onPressed: () => context.go('/login'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF111827),
foregroundColor: Colors.white,
backgroundColor: colorScheme.primary,
foregroundColor: colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
@@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget {
onPressed: () =>
context.go(buildLocalizedHomePath(Uri.base)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF111827),
foregroundColor: colorScheme.onSurface,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
side: const BorderSide(color: Color(0xFFCBD5F5)),
side: BorderSide(color: colorScheme.outline),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),

File diff suppressed because it is too large Load Diff

View File

@@ -26,6 +26,18 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Map<String, dynamic>? _policy;
bool _isPolicyLoading = false;
String _renderTranslatedText(
String key, {
String? fallback,
Map<String, String> values = const {},
}) {
var text = tr(key, fallback: fallback);
values.forEach((name, value) {
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
});
return text;
}
@override
void initState() {
super.initState();
@@ -123,16 +135,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>[
tr(
_renderTranslatedText(
'msg.userfront.reset.policy.min_length',
params: {'count': '$minLength'},
values: {'count': '$minLength'},
),
];
if (minTypes > 0) {
parts.add(
tr(
_renderTranslatedText(
'msg.userfront.reset.policy.min_types',
params: {'count': '$minTypes'},
values: {'count': '$minTypes'},
),
);
}

View File

@@ -69,6 +69,18 @@ class _SignupScreenState extends State<SignupScreen> {
Timer? _phoneTimer;
int _phoneSeconds = 0;
String _renderTranslatedText(
String key, {
String? fallback,
Map<String, String> values = const {},
}) {
var text = tr(key, fallback: fallback);
values.forEach((name, value) {
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
});
return text;
}
@override
void initState() {
super.initState();
@@ -1663,16 +1675,16 @@ class _SignupScreenState extends State<SignupScreen> {
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>[
tr(
_renderTranslatedText(
'msg.userfront.signup.policy.min_length',
params: {'count': minLength.toString()},
values: {'count': minLength.toString()},
),
];
if (minTypes > 0) {
parts.add(
tr(
_renderTranslatedText(
'msg.userfront.signup.policy.min_types',
params: {'count': minTypes.toString()},
values: {'count': minTypes.toString()},
),
);
}
@@ -1689,9 +1701,9 @@ class _SignupScreenState extends State<SignupScreen> {
parts.add(tr('msg.userfront.signup.policy.symbol'));
}
return tr(
return _renderTranslatedText(
'msg.userfront.signup.policy.summary',
params: {'rules': parts.join(', ')},
values: {'rules': parts.join(', ')},
);
}

View File

@@ -0,0 +1,21 @@
import 'providers/linked_rps_provider.dart';
String? resolveLinkedRpLaunchUrl(LinkedRp rp) {
final normalizedStatus = rp.status.trim().toLowerCase();
final isActive = normalizedStatus.isEmpty || normalizedStatus == 'active';
if (!isActive) {
return null;
}
final initUrl = rp.initUrl.trim();
if (initUrl.isNotEmpty) {
return initUrl;
}
final url = rp.url.trim();
if (url.isNotEmpty) {
return url;
}
return null;
}

View File

@@ -96,6 +96,7 @@ class LinkedRp {
final String name;
final String logo;
final String url;
final String initUrl;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
@@ -105,6 +106,7 @@ class LinkedRp {
required this.name,
required this.logo,
required this.url,
required this.initUrl,
required this.status,
required this.scopes,
this.lastAuthenticatedAt,
@@ -126,6 +128,7 @@ class LinkedRp {
name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
status: json['status']?.toString() ?? '',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,
@@ -170,3 +173,59 @@ class RpHistoryItem {
);
}
}
class UserSessionSummary {
final String sessionId;
final DateTime? authenticatedAt;
final DateTime? expiresAt;
final DateTime? issuedAt;
final DateTime? lastSeenAt;
final String ipAddress;
final String userAgent;
final String clientId;
final String appName;
final bool isCurrent;
final bool isActive;
UserSessionSummary({
required this.sessionId,
this.authenticatedAt,
this.expiresAt,
this.issuedAt,
this.lastSeenAt,
required this.ipAddress,
required this.userAgent,
required this.clientId,
required this.appName,
required this.isCurrent,
required this.isActive,
});
factory UserSessionSummary.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic raw) {
final value = raw?.toString();
if (value == null || value.isEmpty) {
return null;
}
try {
return DateTime.parse(value).toLocal();
} catch (_) {
return null;
}
}
return UserSessionSummary(
sessionId: json['session_id']?.toString() ?? '',
authenticatedAt: parseDate(json['authenticated_at']),
expiresAt: parseDate(json['expires_at']),
issuedAt: parseDate(json['issued_at']),
lastSeenAt: parseDate(json['last_seen_at']),
ipAddress: json['ip_address']?.toString() ?? '',
userAgent: json['user_agent']?.toString() ?? '',
clientId: json['client_id']?.toString() ?? '',
appName: json['app_name']?.toString() ?? '',
isCurrent: json['is_current'] == true,
isActive: json['is_active'] != false,
);
}
}

View File

@@ -10,6 +10,7 @@ class LinkedRp {
final String name;
final String logo;
final String url;
final String initUrl;
final String status;
final List<String> scopes;
final DateTime? lastAuthenticatedAt;
@@ -19,6 +20,7 @@ class LinkedRp {
required this.name,
required this.logo,
required this.url,
required this.initUrl,
required this.status,
required this.scopes,
required this.lastAuthenticatedAt,
@@ -40,6 +42,7 @@ class LinkedRp {
name: json['name']?.toString() ?? '',
logo: json['logo']?.toString() ?? '',
url: json['url']?.toString() ?? '',
initUrl: json['init_url']?.toString() ?? '',
status: json['status']?.toString() ?? 'unknown',
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
lastAuthenticatedAt: parsedLastAuth,

View File

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

View File

@@ -3,13 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import 'package:userfront/i18n.dart';
import '../../../../core/notifiers/auth_notifier.dart';
import '../../../../core/i18n/locale_utils.dart';
import '../../../../core/services/auth_proxy_service.dart';
import '../../../../core/services/auth_token_store.dart';
import '../../../../core/services/logout_service.dart';
import '../../../../core/ui/layout_breakpoints.dart';
import '../../../../core/ui/toast_service.dart';
import '../../../../core/widgets/language_selector.dart';
import '../../../../core/widgets/theme_toggle_button.dart';
import '../../data/models/user_profile_model.dart';
import '../../domain/notifiers/profile_notifier.dart';
@@ -21,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget {
}
class _ProfilePageState extends ConsumerState<ProfilePage> {
static const _ink = Color(0xFF1A1F2C);
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
static final _log = Logger('ProfilePage');
UserProfile? _cachedProfile;
@@ -55,9 +51,27 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _showCurrentPassword = false;
bool _showNewPassword = false;
bool _showConfirmPassword = false;
bool _isDesktopSideMenuOpen = true;
Map<String, dynamic>? _passwordPolicy;
bool _isPasswordPolicyLoading = false;
Color get _ink => Theme.of(context).colorScheme.onSurface;
Color get _surface => Theme.of(context).colorScheme.surface;
Color get _border => Theme.of(context).colorScheme.outlineVariant;
Color get _subtle => Theme.of(context).colorScheme.surfaceContainerLowest;
String _renderTranslatedText(
String key, {
String? fallback,
Map<String, String> values = const {},
}) {
var text = tr(key, fallback: fallback);
values.forEach((name, value) {
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
});
return text;
}
@override
void initState() {
super.initState();
@@ -99,16 +113,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
final parts = <String>[
tr(
_renderTranslatedText(
'msg.userfront.signup.policy.min_length',
params: {'count': '$minLength'},
values: {'count': '$minLength'},
),
];
if (minTypes > 0) {
parts.add(
tr(
_renderTranslatedText(
'msg.userfront.signup.policy.min_types',
params: {'count': '$minTypes'},
values: {'count': '$minTypes'},
),
);
}
@@ -125,9 +139,9 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
parts.add(tr('msg.userfront.signup.policy.symbol'));
}
return tr(
return _renderTranslatedText(
'msg.userfront.signup.policy.summary',
params: {'rules': parts.join(", ")},
values: {'rules': parts.join(", ")},
);
}
@@ -164,8 +178,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
}
Future<void> _logout() async {
AuthTokenStore.clear();
AuthNotifier.instance.notify();
await LogoutService().logout();
}
void _ensureControllers(UserProfile profile) {
@@ -605,7 +618,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
),
const Padding(
padding: EdgeInsets.only(bottom: 16),
child: LanguageSelector(compact: true),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ThemeToggleButton(),
SizedBox(height: 8),
LanguageSelector(compact: true),
],
),
),
],
);
@@ -617,7 +637,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
children: [
Text(
title,
style: const TextStyle(
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: _ink,
@@ -644,7 +664,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
style: TextStyle(
fontSize: 12,
color: _ink,
fontWeight: FontWeight.w600,
@@ -690,8 +710,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
tr('msg.userfront.profile.greeting', params: {'name': name}),
style: const TextStyle(
_renderTranslatedText(
'msg.userfront.profile.greeting',
fallback: 'Hello, {{name}}.',
values: {'name': name},
),
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: _ink,
@@ -982,12 +1006,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
const SizedBox(height: 8),
Text(
tr('msg.userfront.profile.password.subtitle'),
style: const TextStyle(color: Color(0xFF6B7280)),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 8),
Text(
_buildPasswordPolicyDescription(),
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurfaceVariant,
fontSize: 12,
),
),
const SizedBox(height: 16),
TextField(
@@ -1217,14 +1246,35 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
return Scaffold(
backgroundColor: _subtle,
appBar: AppBar(
leading: isWide
? IconButton(
icon: Icon(
_isDesktopSideMenuOpen ? Icons.menu_open : Icons.menu,
),
tooltip: _isDesktopSideMenuOpen
? tr('ui.common.collapse')
: '펼치기',
onPressed: () {
setState(() {
_isDesktopSideMenuOpen = !_isDesktopSideMenuOpen;
});
},
)
: Builder(
builder: (context) => IconButton(
icon: const Icon(Icons.menu),
tooltip: MaterialLocalizations.of(
context,
).openAppDrawerTooltip,
onPressed: () => Scaffold.of(context).openDrawer(),
),
),
title: Text(
tr('ui.userfront.app_title'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: _surface,
foregroundColor: Colors.black,
actions: [
const ThemeToggleButton(compact: true),
IconButton(
icon: const Icon(Icons.home_outlined),
tooltip: tr('ui.userfront.nav.dashboard'),
@@ -1245,7 +1295,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
body: Row(
children: [
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
if (isWide && _isDesktopSideMenuOpen)
SizedBox(width: 240, child: _buildSideMenu(context)),
Expanded(child: _buildContent(profile, isUpdating)),
],
),

File diff suppressed because one or more lines are too long

View File

@@ -24,6 +24,9 @@ import 'core/services/logger_service.dart';
import 'core/services/null_check_recovery.dart';
import 'core/services/web_window.dart';
import 'core/notifiers/auth_notifier.dart';
import 'core/theme/app_theme.dart';
import 'core/theme/theme_controller.dart';
import 'core/theme/theme_scope.dart';
import 'core/i18n/locale_gate.dart';
import 'core/i18n/locale_registry.dart';
import 'core/i18n/locale_utils.dart';
@@ -106,6 +109,8 @@ void main() async {
// 0. Initialize Logger
LoggerService.init();
await ThemeController.app.restore();
await ThemeController.auth.restore();
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
await _loadBundledFonts();
@@ -177,12 +182,18 @@ final _router = GoRouter(
GoRoute(
path: 'dashboard',
builder: (context, state) {
return const DashboardScreen();
return ScopedTheme(
controller: ThemeController.app,
child: const DashboardScreen(),
);
},
),
GoRoute(
path: 'profile',
builder: (context, state) => const ProfilePage(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const ProfilePage(),
),
),
GoRoute(
path: 'signin',
@@ -192,10 +203,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
);
},
),
@@ -208,10 +222,13 @@ final _router = GoRouter(
final redirectUrl =
state.uri.queryParameters['redirect_uri'] ??
state.uri.queryParameters['redirect_url'];
return LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
loginChallenge: loginChallenge,
redirectUrl: redirectUrl,
),
);
},
),
@@ -227,88 +244,137 @@ final _router = GoRouter(
),
);
}
return ConsentScreen(consentChallenge: consentChallenge);
return ScopedTheme(
controller: ThemeController.auth,
child: ConsentScreen(consentChallenge: consentChallenge),
);
},
),
GoRoute(
path: 'signup',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'registration',
builder: (context, state) => const SignupScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const SignupScreen(),
),
),
GoRoute(
path: 'verify',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'verify/:token',
builder: (context, state) {
final token = state.pathParameters['token'];
return LoginScreen(
key: state.pageKey,
verificationToken: token,
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(
key: state.pageKey,
verificationToken: token,
),
);
},
),
GoRoute(
path: 'verification',
builder: (context, state) => LoginScreen(key: state.pageKey),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
),
),
GoRoute(
path: 'l/:shortCode',
builder: (context, state) {
return LoginScreen(key: state.pageKey);
return ScopedTheme(
controller: ThemeController.auth,
child: LoginScreen(key: state.pageKey),
);
},
),
GoRoute(
path: 'forgot-password',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'recovery',
builder: (context, state) => const ForgotPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ForgotPasswordScreen(),
),
),
GoRoute(
path: 'reset-password',
builder: (context, state) => const ResetPasswordScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const ResetPasswordScreen(),
),
),
GoRoute(
path: 'error',
builder: (context, state) {
final params = state.uri.queryParameters;
return ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description: params['error_description'] ?? params['message'],
return ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorId: params['id'],
errorCode: params['error'],
description:
params['error_description'] ?? params['message'],
),
);
},
),
GoRoute(
path: 'settings',
builder: (context, state) => ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ErrorScreen(
errorCode: 'settings_disabled',
description: tr('msg.userfront.settings.disabled'),
),
),
),
GoRoute(
path: 'approve',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(
pendingRef: state.uri.queryParameters['ref'],
),
),
),
GoRoute(
path: 'ql/:ref',
builder: (context, state) =>
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
),
),
GoRoute(
path: 'scan',
builder: (context, state) => const QRScanScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.auth,
child: const QRScanScreen(),
),
),
GoRoute(
path: 'admin/users',
builder: (context, state) => const UserManagementScreen(),
builder: (context, state) => ScopedTheme(
controller: ThemeController.app,
child: const UserManagementScreen(),
),
),
],
),
@@ -376,40 +442,10 @@ class BaronSSOApp extends StatelessWidget {
children: [if (child != null) child, const ToastViewport()],
);
},
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
brightness: Brightness.light,
),
useMaterial3: true,
fontFamily: 'NotoSansKR',
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: NoTransitionsBuilder(),
TargetPlatform.iOS: NoTransitionsBuilder(),
TargetPlatform.linux: NoTransitionsBuilder(),
TargetPlatform.macOS: NoTransitionsBuilder(),
TargetPlatform.windows: NoTransitionsBuilder(),
TargetPlatform.fuchsia: NoTransitionsBuilder(),
},
),
),
theme: buildLightTheme(),
darkTheme: buildDarkTheme(),
themeMode: ThemeMode.light,
routerConfig: _router,
);
}
}
class NoTransitionsBuilder extends PageTransitionsBuilder {
const NoTransitionsBuilder();
@override
Widget buildTransitions<T>(
PageRoute<T> route,
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return child;
}
}

View File

@@ -184,6 +184,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.0"
flutter_svg:
dependency: "direct main"
description:
name: flutter_svg
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
url: "https://pub.dev"
source: hosted
version: "2.2.4"
flutter_test:
dependency: "direct dev"
description: flutter
@@ -388,6 +396,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_parsing:
dependency: transitive
description:
name: path_parsing
sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
path_provider_linux:
dependency: transitive
description:
@@ -485,7 +501,7 @@ packages:
source: hosted
version: "3.2.0"
shared_preferences:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
@@ -753,6 +769,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.5"
vector_graphics:
dependency: transitive
description:
name: vector_graphics
sha256: "81da85e9ca8885ade47f9685b953cb098970d11be4821ac765580a6607ea4373"
url: "https://pub.dev"
source: hosted
version: "1.1.21"
vector_graphics_codec:
dependency: transitive
description:
name: vector_graphics_codec
sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146"
url: "https://pub.dev"
source: hosted
version: "1.1.13"
vector_graphics_compiler:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "5a88dd14c0954a5398af544651c7fb51b457a2a556949bfb25369b210ef73a74"
url: "https://pub.dev"
source: hosted
version: "1.2.0"
vector_math:
dependency: transitive
description:
@@ -825,6 +865,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
xml:
dependency: transitive
description:
name: xml
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
url: "https://pub.dev"
source: hosted
version: "6.5.0"
yaml:
dependency: transitive
description:

View File

@@ -40,6 +40,7 @@ dependencies:
go_router: ^17.0.1
http: ^1.6.0
flutter_dotenv: ^6.0.0
flutter_svg: ^2.2.1
url_launcher: ^6.3.2
logging: ^1.2.0
logger: ^2.0.0
@@ -48,6 +49,7 @@ dependencies:
easy_localization: ^3.0.7
toml: ^0.15.0
web: ^1.1.0
shared_preferences: ^2.5.4
dev_dependencies:
flutter_test:

View File

@@ -0,0 +1,72 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/features/dashboard/domain/linked_rp_launch.dart';
import 'package:userfront/features/dashboard/domain/providers/linked_rps_provider.dart';
LinkedRp _linkedRp({
required String status,
String url = '',
String initUrl = '',
}) {
return LinkedRp(
id: 'client-1',
name: 'Example App',
logo: '',
url: url,
initUrl: initUrl,
status: status,
scopes: const ['openid', 'profile'],
lastAuthenticatedAt: null,
);
}
void main() {
test('LinkedRp.fromJson은 init_url을 읽는다', () {
final rp = LinkedRp.fromJson({
'id': 'client-1',
'name': 'Example App',
'status': 'active',
'url': 'https://example.com',
'init_url': 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
});
expect(
rp.initUrl,
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
);
});
test('활성 앱은 initUrl을 우선 진입 URL로 사용한다', () {
final launchUrl = resolveLinkedRpLaunchUrl(
_linkedRp(
status: 'active',
url: 'https://example.com',
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
),
);
expect(
launchUrl,
'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
);
});
test('활성 앱은 initUrl이 없으면 기존 url로 폴백한다', () {
final launchUrl = resolveLinkedRpLaunchUrl(
_linkedRp(status: 'active', url: 'https://example.com'),
);
expect(launchUrl, 'https://example.com');
});
test('비활성 앱은 진입 URL을 만들지 않는다', () {
final launchUrl = resolveLinkedRpLaunchUrl(
_linkedRp(
status: 'inactive',
url: 'https://example.com',
initUrl: 'https://sso.example.com/oidc/oauth2/auth?client_id=client-1',
),
);
expect(launchUrl, isNull);
});
}

View File

@@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:userfront/core/services/logout_service.dart';
void main() {
test('현재 세션이 있으면 서버 세션 종료 후 로컬 로그아웃을 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return 'current-sid';
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
});
test('현재 세션이 없으면 서버 세션 종료 없이 로컬 로그아웃만 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return null;
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'clear', 'notify']);
});
test('서버 세션 종료가 실패해도 로컬 로그아웃은 계속 진행한다', () async {
final events = <String>[];
final service = LogoutService(
loadCurrentSessionId: () async {
events.add('load');
return 'current-sid';
},
revokeSession: (sessionId) async {
events.add('revoke:$sessionId');
throw Exception('revoke failed');
},
clearAuth: () {
events.add('clear');
},
notifyAuthChanged: () {
events.add('notify');
},
);
await service.logout();
expect(events, ['load', 'revoke:current-sid', 'clear', 'notify']);
});
}

View File

@@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:userfront/core/theme/theme_controller.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUp(() async {
SharedPreferences.setMockInitialValues({});
await ThemeController.app.setThemeMode(ThemeMode.light);
});
test('저장된 dark 값을 복원한다', () async {
SharedPreferences.setMockInitialValues({
ThemeController.appStorageKey: 'dark',
});
await ThemeController.app.restore();
expect(ThemeController.app.value, ThemeMode.dark);
});
test('toggle 결과를 저장한다', () async {
await ThemeController.app.restore();
await ThemeController.app.toggle();
final prefs = await SharedPreferences.getInstance();
expect(ThemeController.app.value, ThemeMode.dark);
expect(prefs.getString(ThemeController.appStorageKey), 'dark');
});
}