forked from baron/baron-sso
Merge branch 'dev' into fix/rebac-env-sync-issue
This commit is contained in:
13
Makefile
13
Makefile
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1501,6 +1501,7 @@ ory = ""
|
||||
session = ""
|
||||
|
||||
[ui.userfront.dashboard]
|
||||
link_status_label = ""
|
||||
last_auth_label = ""
|
||||
status_history = ""
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
685
backend/internal/handler/auth_handler_sessions_test.go
Normal file
685
backend/internal/handler/auth_handler_sessions_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
87
backend/internal/utils/client_ip.go
Normal file
87
backend/internal/utils/client_ip.go
Normal 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()
|
||||
}
|
||||
24
backend/internal/utils/client_ip_test.go
Normal file
24
backend/internal/utils/client_ip_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "애플리케이션 정보"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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."
|
||||
|
||||
440
locales/ko.toml
440
locales/ko.toml
@@ -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 = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
200
userfront-e2e/tests/session-cross-browser-debug.spec.ts
Normal file
200
userfront-e2e/tests/session-cross-browser-debug.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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)}}',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
39
userfront/lib/core/services/logout_service.dart
Normal file
39
userfront/lib/core/services/logout_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
148
userfront/lib/core/theme/app_theme.dart
Normal file
148
userfront/lib/core/theme/app_theme.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
37
userfront/lib/core/theme/theme_controller.dart
Normal file
37
userfront/lib/core/theme/theme_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/theme/theme_scope.dart
Normal file
44
userfront/lib/core/theme/theme_scope.dart
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal file
44
userfront/lib/core/widgets/theme_toggle_button.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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'},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(', ')},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
72
userfront/test/linked_rp_launch_test.dart
Normal file
72
userfront/test/linked_rp_launch_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
74
userfront/test/logout_service_test.dart
Normal file
74
userfront/test/logout_service_test.dart
Normal 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']);
|
||||
});
|
||||
}
|
||||
32
userfront/test/theme_controller_test.dart
Normal file
32
userfront/test/theme_controller_test.dart
Normal 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');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user