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
|
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),)
|
ifeq ($(CI),)
|
||||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install
|
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 := npx playwright install chromium
|
PLAYWRIGHT_INSTALL_CHROMIUM := sh -c 'if [ -f "$(PLAYWRIGHT_CHROMIUM_COMPLETE)" ]; then echo "Playwright chromium already installed"; else npx playwright install chromium; fi'
|
||||||
else
|
else
|
||||||
PLAYWRIGHT_INSTALL_ALL := npx playwright install --with-deps
|
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 := npx playwright install --with-deps chromium
|
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
|
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
|
.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"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"lint:fix": "biome check . --write",
|
"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 { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { fetchMe } from "../../lib/adminApi";
|
import { fetchMe } from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
import {
|
||||||
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
|
} from "../../lib/sessionSliding";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import RoleSwitcher from "./RoleSwitcher";
|
import RoleSwitcher from "./RoleSwitcher";
|
||||||
|
|
||||||
@@ -221,6 +224,52 @@ function AppLayout() {
|
|||||||
isSessionExpiryEnabled,
|
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(() => {
|
useEffect(() => {
|
||||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||||
if (lastVisitedRouteRef.current === null) {
|
if (lastVisitedRouteRef.current === null) {
|
||||||
|
|||||||
@@ -14,7 +14,14 @@ function AuthCallbackPage() {
|
|||||||
if (user?.access_token) {
|
if (user?.access_token) {
|
||||||
window.localStorage.setItem("admin_session", 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) {
|
} else if (auth.error) {
|
||||||
console.error("Auth Error:", auth.error);
|
console.error("Auth Error:", auth.error);
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
|
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -11,10 +13,40 @@ import {
|
|||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
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 = () => {
|
const handleSSOLogin = () => {
|
||||||
// OIDC client-side authentication flow started here
|
void auth.signinRedirect({
|
||||||
auth.signinRedirect();
|
state: {
|
||||||
|
returnTo: "/",
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const oidcConfig: AuthProviderProps = {
|
|||||||
scope: "openid offline_access profile email", // offline_access for refresh token
|
scope: "openid offline_access profile email", // offline_access for refresh token
|
||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
automaticSilentRenew: true,
|
automaticSilentRenew: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userManager = new UserManager({
|
export const userManager = new UserManager({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
SESSION_RENEW_THRESHOLD_MS,
|
SESSION_RENEW_THRESHOLD_MS,
|
||||||
shouldAttemptSlidingSessionRenew,
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
} from "./sessionSliding";
|
} from "./sessionSliding";
|
||||||
|
|
||||||
describe("shouldAttemptSlidingSessionRenew", () => {
|
describe("shouldAttemptSlidingSessionRenew", () => {
|
||||||
@@ -71,3 +72,55 @@ describe("shouldAttemptSlidingSessionRenew", () => {
|
|||||||
).toBe(false);
|
).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;
|
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 = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
envPrefix: ["VITE_", "USERFRONT_"],
|
envPrefix: ["VITE_", "USERFRONT_"],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sadmin.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -121,6 +121,18 @@ func (m *e2eMockKratosAdminService) DeleteIdentity(ctx context.Context, identity
|
|||||||
return nil
|
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 {
|
func newHeadlessLoginE2EApp(h *authhandler.AuthHandler, appEnv string) *fiber.App {
|
||||||
app := fiber.New(fiber.Config{
|
app := fiber.New(fiber.Config{
|
||||||
DisableStartupMessage: true,
|
DisableStartupMessage: true,
|
||||||
|
|||||||
@@ -582,6 +582,8 @@ func main() {
|
|||||||
user.Post("/me/password", authHandler.ChangeMyPassword)
|
user.Post("/me/password", authHandler.ChangeMyPassword)
|
||||||
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
user.Post("/me/send-code", authHandler.SendUpdateCode)
|
||||||
user.Post("/me/verify-code", authHandler.VerifyUpdateCode)
|
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/linked", authHandler.ListLinkedRps)
|
||||||
user.Get("/rp/history", authHandler.ListRpHistory)
|
user.Get("/rp/history", authHandler.ListRpHistory)
|
||||||
user.Delete("/rp/linked/:id", authHandler.RevokeLinkedRp)
|
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)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error {
|
||||||
args := m.Called(ctx, user)
|
args := m.Called(ctx, user)
|
||||||
if m.createCalled != nil {
|
if m.createCalled != nil {
|
||||||
@@ -87,6 +88,7 @@ func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error
|
|||||||
}
|
}
|
||||||
return args.Error(0)
|
return args.Error(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
|
||||||
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -45,11 +46,14 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
return httpJSONAny(r, http.StatusOK, []map[string]interface{}{
|
||||||
{
|
{
|
||||||
"client": map[string]interface{}{
|
"client": map[string]interface{}{
|
||||||
"client_id": "client-active",
|
"client_id": "devfront",
|
||||||
"client_name": "Active App",
|
"client_name": "DevFront",
|
||||||
|
"redirect_uris": []string{
|
||||||
|
"https://active.example.com/callback",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"granted_scope": []string{"openid"},
|
"grant_scope": []string{"openid", "profile"},
|
||||||
"handled_at": time.Now().Format(time.RFC3339),
|
"handled_at": time.Now().Format(time.RFC3339),
|
||||||
},
|
},
|
||||||
}), nil
|
}), nil
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,8 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
|
|
||||||
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")
|
||||||
t.Setenv("KRATOS_ADMIN_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)
|
app := newLinkedRpTestApp(h)
|
||||||
|
|
||||||
@@ -123,10 +129,11 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
|
|
||||||
var res struct {
|
var res struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Scopes []string `json:"scopes"`
|
Scopes []string `json:"scopes"`
|
||||||
|
InitURL string `json:"init_url"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
json.NewDecoder(resp.Body).Decode(&res)
|
json.NewDecoder(resp.Body).Decode(&res)
|
||||||
@@ -138,7 +145,108 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
|
|||||||
statusMap[item.ID] = item.Status
|
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-consent"])
|
||||||
assert.Equal(t, "inactive", statusMap["client-audit"])
|
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 (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/middleware"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
@@ -122,6 +123,27 @@ func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID
|
|||||||
return nil
|
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 ---
|
// --- Helper ---
|
||||||
|
|
||||||
func newAuthLoginTestApp(h *AuthHandler) *fiber.App {
|
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) {
|
func TestHeadlessPasswordLogin_HeadlessLoginClientSuccess(t *testing.T) {
|
||||||
mockIdp := new(MockIdentityProvider)
|
mockIdp := new(MockIdentityProvider)
|
||||||
mockIdp.On("SignIn", "employee001", "password").Return(&domain.AuthInfo{
|
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 }
|
func (m *mockAuditRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
|
type mockOathkeeperRepo struct {
|
||||||
|
logs []domain.OathkeeperAccessLog
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockOathkeeperRepo) FindPageBySubject(ctx context.Context, subject string, limit int, cursor *domain.AuditCursor) ([]domain.OathkeeperAccessLog, error) {
|
||||||
|
if subject == "" {
|
||||||
|
return m.logs, nil
|
||||||
|
}
|
||||||
|
results := make([]domain.OathkeeperAccessLog, 0, len(m.logs))
|
||||||
|
for _, log := range m.logs {
|
||||||
|
if log.Subject == subject {
|
||||||
|
results = append(results, log)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockOathkeeperRepo) Ping(ctx context.Context) error { return nil }
|
||||||
|
|
||||||
// --- Mock Consent Repository ---
|
// --- Mock Consent Repository ---
|
||||||
|
|
||||||
type mockConsentRepo struct {
|
type mockConsentRepo struct {
|
||||||
|
|||||||
@@ -56,6 +56,26 @@ func (m *MockKratosAdmin) DeleteIdentity(ctx context.Context, id string) error {
|
|||||||
return m.Called(ctx, id).Error(0)
|
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 {
|
type MockOryProvider struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -217,16 +216,5 @@ func AuditMiddleware(config AuditConfig) fiber.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func extractClientIP(c *fiber.Ctx) string {
|
func extractClientIP(c *fiber.Ctx) string {
|
||||||
if forwarded := c.Get("X-Forwarded-For"); forwarded != "" {
|
return utils.ResolveClientIP(c.Get("X-Forwarded-For"), c.Get("X-Real-IP"), c.IP())
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,30 @@ func TestAuditMiddleware(t *testing.T) {
|
|||||||
mockRepo.AssertExpectations(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) {
|
t.Run("POST request - Sync Failure (Strict Mode)", func(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockRepo := new(MockAuditRepository)
|
mockRepo := new(MockAuditRepository)
|
||||||
|
|||||||
@@ -264,6 +264,8 @@ func (s *HydraAdminService) RevokeConsentSessions(ctx context.Context, subject,
|
|||||||
}
|
}
|
||||||
if clientID != "" {
|
if clientID != "" {
|
||||||
params["client"] = clientID
|
params["client"] = clientID
|
||||||
|
} else {
|
||||||
|
params["all"] = "true"
|
||||||
}
|
}
|
||||||
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
|
endpoint, err := s.buildURLWithParams("/oauth2/auth/sessions/consent", params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ type KratosIdentity struct {
|
|||||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
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 {
|
type KratosAdminService interface {
|
||||||
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
|
||||||
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, 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 {
|
if user == nil {
|
||||||
return "", fmt.Errorf("kratos admin: user payload is nil")
|
return "", fmt.Errorf("kratos admin: user payload is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
traits := map[string]interface{}{
|
traits := map[string]interface{}{
|
||||||
"email": user.Email,
|
"email": user.Email,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
|
|||||||
@@ -116,5 +116,3 @@ func (m *MockKratosAdminServiceShared) CreateUser(ctx context.Context, user *dom
|
|||||||
args := m.Called(ctx, user, password)
|
args := m.Called(ctx, user, password)
|
||||||
return args.String(0), args.Error(1)
|
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)
|
return args.Get(0).(map[string]int64), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
args := m.Called(ctx, codes)
|
args := m.Called(ctx, codes)
|
||||||
if args.Get(0) == nil {
|
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"
|
"node": ">=24.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 0.0.0.0",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "biome check .",
|
"lint": "biome check .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
|||||||
import { fetchMe } from "../../features/auth/authApi";
|
import { fetchMe } from "../../features/auth/authApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { resolveProfileRole } from "../../lib/role";
|
import { resolveProfileRole } from "../../lib/role";
|
||||||
import { shouldAttemptSlidingSessionRenew } from "../../lib/sessionSliding";
|
import {
|
||||||
|
shouldAttemptSlidingSessionRenew,
|
||||||
|
shouldAttemptUnlimitedSessionRenew,
|
||||||
|
} from "../../lib/sessionSliding";
|
||||||
import LanguageSelector from "../common/LanguageSelector";
|
import LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
import { Toaster } from "../ui/toaster";
|
||||||
|
|
||||||
@@ -151,6 +154,52 @@ function AppLayout() {
|
|||||||
isSessionExpiryEnabled,
|
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(() => {
|
useEffect(() => {
|
||||||
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
const routeKey = `${location.pathname}${location.search}${location.hash}`;
|
||||||
if (lastVisitedRouteRef.current === null) {
|
if (lastVisitedRouteRef.current === null) {
|
||||||
|
|||||||
@@ -17,12 +17,19 @@ export default function AuthCallbackPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (auth.isAuthenticated) {
|
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) {
|
} else if (auth.error) {
|
||||||
console.error("Auth Error:", auth.error);
|
console.error("Auth Error:", auth.error);
|
||||||
navigate("/login", { replace: true });
|
navigate("/login", { replace: true });
|
||||||
}
|
}
|
||||||
}, [auth.isAuthenticated, auth.error, navigate]);
|
}, [auth.isAuthenticated, auth.error, navigate, auth.user?.state]);
|
||||||
|
|
||||||
return <div>Loading Auth...</div>;
|
return <div>Loading Auth...</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -14,18 +15,42 @@ import {
|
|||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const autoStartedRef = useRef(false);
|
||||||
|
const returnTo = searchParams.get("returnTo") || "/clients";
|
||||||
|
const shouldAutoLogin = searchParams.get("auto") === "1";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (auth.isAuthenticated) {
|
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 () => {
|
const handleSSOLogin = async () => {
|
||||||
try {
|
try {
|
||||||
await auth.signinPopup();
|
await auth.signinRedirect({
|
||||||
|
state: {
|
||||||
|
returnTo: "/clients",
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Popup login failed", error);
|
console.error("Redirect login failed", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Shield,
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -44,7 +44,7 @@ function ClientDetailsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const clientId = params.id ?? "";
|
const clientId = params.id ?? "";
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, error, isLoading } = useQuery({
|
||||||
queryKey: ["client", clientId],
|
queryKey: ["client", clientId],
|
||||||
queryFn: () => fetchClient(clientId),
|
queryFn: () => fetchClient(clientId),
|
||||||
enabled: clientId.length > 0,
|
enabled: clientId.length > 0,
|
||||||
@@ -52,12 +52,18 @@ function ClientDetailsPage() {
|
|||||||
|
|
||||||
const [redirectUris, setRedirectUris] = useState("");
|
const [redirectUris, setRedirectUris] = useState("");
|
||||||
const [showSecret, setShowSecret] = useState(false);
|
const [showSecret, setShowSecret] = useState(false);
|
||||||
|
const redirectUrisHydratedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data?.client?.redirectUris) {
|
if (
|
||||||
|
!redirectUrisHydratedRef.current &&
|
||||||
|
data?.client?.redirectUris &&
|
||||||
|
redirectUris === ""
|
||||||
|
) {
|
||||||
setRedirectUris(data.client.redirectUris.join(", "));
|
setRedirectUris(data.client.redirectUris.join(", "));
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
}
|
}
|
||||||
}, [data]);
|
}, [data, redirectUris]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
@@ -129,15 +135,7 @@ function ClientDetailsPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (error && !data) {
|
||||||
return (
|
|
||||||
<div className="p-8 text-center">
|
|
||||||
{t("msg.dev.clients.details.loading", "Loading app...")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error || !data) {
|
|
||||||
const errMsg =
|
const errMsg =
|
||||||
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
(error as AxiosError<{ error?: string }>).response?.data?.error ??
|
||||||
(error as Error)?.message;
|
(error as Error)?.message;
|
||||||
@@ -152,37 +150,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 = [
|
const endpoints = [
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
labelKey: "ui.dev.clients.details.endpoint.discovery",
|
||||||
labelFallback: "Discovery Endpoint",
|
labelFallback: "Discovery Endpoint",
|
||||||
value: data.endpoints.discovery,
|
value: endpointValues.discovery,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
labelKey: "ui.dev.clients.details.endpoint.issuer",
|
||||||
labelFallback: "Issuer URL",
|
labelFallback: "Issuer URL",
|
||||||
value: data.endpoints.issuer,
|
value: endpointValues.issuer,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
labelKey: "ui.dev.clients.details.endpoint.authorization",
|
||||||
labelFallback: "Authorization Endpoint",
|
labelFallback: "Authorization Endpoint",
|
||||||
value: data.endpoints.authorization,
|
value: endpointValues.authorization,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.token",
|
labelKey: "ui.dev.clients.details.endpoint.token",
|
||||||
labelFallback: "Token Endpoint",
|
labelFallback: "Token Endpoint",
|
||||||
value: data.endpoints.token,
|
value: endpointValues.token,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
labelKey: "ui.dev.clients.details.endpoint.userinfo",
|
||||||
labelFallback: "UserInfo Endpoint",
|
labelFallback: "UserInfo Endpoint",
|
||||||
value: data.endpoints.userinfo,
|
value: endpointValues.userinfo,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Client Secret from API
|
// Client Secret from API
|
||||||
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
const secretPlaceholder = "SECRET_NOT_AVAILABLE";
|
||||||
const clientSecret = data.client.clientSecret || secretPlaceholder;
|
const clientSecret = client?.clientSecret || secretPlaceholder;
|
||||||
const displaySecret =
|
const displaySecret =
|
||||||
clientSecret === secretPlaceholder
|
clientSecret === secretPlaceholder
|
||||||
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
? t("msg.dev.clients.details.secret_unavailable", "SECRET_NOT_AVAILABLE")
|
||||||
@@ -200,7 +217,7 @@ function ClientDetailsPage() {
|
|||||||
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
|
||||||
</Link>
|
</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span>{data.client.name || clientId}</span>
|
<span>{client?.name || clientId}</span>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-foreground font-semibold">
|
<span className="text-foreground font-semibold">
|
||||||
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
{t("ui.dev.clients.details.tab.connection", "Federation")}
|
||||||
@@ -215,7 +232,7 @@ function ClientDetailsPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
<h1 className="text-4xl font-black leading-tight tracking-tight">
|
||||||
{data.client.name || data.client.id}
|
{client?.name || client?.id || clientId}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
@@ -226,12 +243,14 @@ function ClientDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={data.client.status === "active" ? "info" : "muted"}
|
variant={client?.status === "active" ? "info" : "muted"}
|
||||||
className="px-3 py-1 text-xs uppercase"
|
className="px-3 py-1 text-xs uppercase"
|
||||||
>
|
>
|
||||||
{data.client.status === "active"
|
{client?.status === "active"
|
||||||
? t("ui.common.status.active", "Active")
|
? t("ui.common.status.active", "Active")
|
||||||
: t("ui.common.status.inactive", "Inactive")}
|
: client?.status === "inactive"
|
||||||
|
? t("ui.common.status.inactive", "Inactive")
|
||||||
|
: t("msg.common.loading", "Loading...")}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6 border-b border-border">
|
<div className="flex gap-6 border-b border-border">
|
||||||
@@ -276,10 +295,10 @@ function ClientDetailsPage() {
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="font-mono text-lg truncate">
|
<p className="font-mono text-lg truncate">
|
||||||
{data.client.id}
|
{client?.id || clientId}
|
||||||
</p>
|
</p>
|
||||||
<CopyButton
|
<CopyButton
|
||||||
value={data.client.id}
|
value={client?.id || clientId}
|
||||||
onCopy={() =>
|
onCopy={() =>
|
||||||
toast(
|
toast(
|
||||||
t(
|
t(
|
||||||
@@ -461,7 +480,10 @@ function ClientDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
rows={5}
|
rows={5}
|
||||||
value={redirectUris}
|
value={redirectUris}
|
||||||
onChange={(e) => setRedirectUris(e.target.value)}
|
onChange={(e) => {
|
||||||
|
redirectUrisHydratedRef.current = true;
|
||||||
|
setRedirectUris(e.target.value);
|
||||||
|
}}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ExternalLink,
|
||||||
Info,
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
@@ -133,6 +134,9 @@ function ClientGeneralPage() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
const [logoUrl, setLogoUrl] = useState("");
|
const [logoUrl, setLogoUrl] = useState("");
|
||||||
|
const [logoPreviewStatus, setLogoPreviewStatus] = useState<
|
||||||
|
"idle" | "loading" | "loaded" | "error"
|
||||||
|
>("idle");
|
||||||
const [clientType, setClientType] = useState<ClientType>("private");
|
const [clientType, setClientType] = useState<ClientType>("private");
|
||||||
const [status, setStatus] = useState<ClientStatus>("active");
|
const [status, setStatus] = useState<ClientStatus>("active");
|
||||||
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
const [initialStatus, setInitialStatus] = useState<ClientStatus>("active");
|
||||||
@@ -240,6 +244,21 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const securityProfile: SecurityProfile =
|
const securityProfile: SecurityProfile =
|
||||||
clientType === "pkce" ? "pkce" : "private";
|
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) => {
|
const handleSecurityProfileChange = (profile: SecurityProfile) => {
|
||||||
setClientType(profile);
|
setClientType(profile);
|
||||||
@@ -438,6 +457,15 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: async () => {
|
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 scopeNames = scopes.map((scope) => scope.name).filter(Boolean);
|
||||||
|
|
||||||
const effectiveTokenEndpointAuthMethod =
|
const effectiveTokenEndpointAuthMethod =
|
||||||
@@ -457,7 +485,7 @@ function ClientGeneralPage() {
|
|||||||
: undefined,
|
: undefined,
|
||||||
metadata: {
|
metadata: {
|
||||||
description,
|
description,
|
||||||
logo_url: logoUrl,
|
logo_url: trimmedLogoUrl,
|
||||||
structured_scopes: scopes,
|
structured_scopes: scopes,
|
||||||
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
token_endpoint_auth_method: effectiveTokenEndpointAuthMethod,
|
||||||
headless_login_enabled: headlessLoginEnabled,
|
headless_login_enabled: headlessLoginEnabled,
|
||||||
@@ -722,6 +750,8 @@ function ClientGeneralPage() {
|
|||||||
<Input
|
<Input
|
||||||
value={logoUrl}
|
value={logoUrl}
|
||||||
onChange={(e) => setLogoUrl(e.target.value)}
|
onChange={(e) => setLogoUrl(e.target.value)}
|
||||||
|
aria-invalid={!hasValidLogoUrl}
|
||||||
|
className={!hasValidLogoUrl ? "border-destructive" : ""}
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.dev.clients.general.identity.logo_placeholder",
|
"ui.dev.clients.general.identity.logo_placeholder",
|
||||||
"https://example.com/logo.png",
|
"https://example.com/logo.png",
|
||||||
@@ -733,19 +763,102 @@ function ClientGeneralPage() {
|
|||||||
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
"인증 화면에 표시될 PNG/SVG URL입니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</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>
|
||||||
<div className="flex h-20 w-20 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/40 shrink-0">
|
<div
|
||||||
{logoUrl ? (
|
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
|
<img
|
||||||
src={logoUrl}
|
key={trimmedLogoUrl}
|
||||||
|
src={trimmedLogoUrl}
|
||||||
alt={t(
|
alt={t(
|
||||||
"ui.dev.clients.general.identity.logo_preview",
|
"ui.dev.clients.general.identity.logo_preview",
|
||||||
"Logo Preview",
|
"Logo Preview",
|
||||||
)}
|
)}
|
||||||
className="h-full w-full object-contain"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,17 +27,23 @@ apiClient.interceptors.request.use(async (config) => {
|
|||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const status = error.response?.status;
|
||||||
// 401 발생 시 로그인 페이지로 리다이렉트
|
const message =
|
||||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
error.response?.data?.error?.toString().toLowerCase() ??
|
||||||
const isLoginPath = window.location.pathname === "/login";
|
error.response?.data?.message?.toString().toLowerCase() ??
|
||||||
const user = await userManager.getUser();
|
"";
|
||||||
// 인증 토큰이 없는 경우에만 로그인으로 보낸다.
|
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
||||||
// 토큰이 있는데 401이면 권한/백엔드 정책 이슈로 간주하고 화면에서 에러를 노출한다.
|
const isLoginPath = window.location.pathname === "/login";
|
||||||
const hasAccessToken = Boolean(user?.access_token);
|
const shouldRedirectToLogin =
|
||||||
if (!hasAccessToken && !isAuthPath && !isLoginPath) {
|
status === 401 ||
|
||||||
window.location.href = "/login";
|
(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);
|
return Promise.reject(error);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const oidcConfig: AuthProviderProps = {
|
|||||||
post_logout_redirect_uri: window.location.origin,
|
post_logout_redirect_uri: window.location.origin,
|
||||||
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
popup_redirect_uri: `${window.location.origin}/auth/callback`,
|
||||||
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
userStore: new WebStorageStateStore({ store: window.localStorage }),
|
||||||
automaticSilentRenew: true,
|
automaticSilentRenew: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const userManager = new UserManager({
|
export const userManager = new UserManager({
|
||||||
|
|||||||
@@ -43,3 +43,34 @@ export function shouldAttemptSlidingSessionRenew({
|
|||||||
|
|
||||||
return true;
|
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]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "PNG or SVG URL shown on the consent and authentication screens."
|
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."
|
subtitle = "Set the application name, description, and logo."
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1378,6 +1382,9 @@ description_placeholder = "Description Placeholder"
|
|||||||
logo = "App Logo URL"
|
logo = "App Logo URL"
|
||||||
logo_placeholder = "https://example.com/logo.png"
|
logo_placeholder = "https://example.com/logo.png"
|
||||||
logo_preview = "Logo Preview"
|
logo_preview = "Logo Preview"
|
||||||
|
logo_open = "Open in new tab"
|
||||||
|
logo_preview_error_badge = "Preview failed"
|
||||||
|
logo_preview_empty = "Preview"
|
||||||
name = "Name"
|
name = "Name"
|
||||||
name_placeholder = "My Awesome Application"
|
name_placeholder = "My Awesome Application"
|
||||||
title = "Application Identity"
|
title = "Application Identity"
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ subtitle = "이 애플리케이션의 외부 IdP 설정을 관리합니다."
|
|||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
logo_help = "인증 화면에 표시될 PNG/SVG URL입니다."
|
||||||
|
logo_invalid = "앱 로고 URL 형식이 올바르지 않습니다. http 또는 https 주소를 입력하세요."
|
||||||
|
logo_preview_loading = "로고 미리보기를 불러오는 중입니다."
|
||||||
|
logo_preview_ready = "로고 미리보기를 확인했습니다."
|
||||||
|
logo_preview_failed = "로고 미리보기를 불러오지 못했습니다. URL 또는 이미지 접근 권한을 확인하세요."
|
||||||
subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
subtitle = "앱 이름과 설명, 로고를 설정합니다."
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1377,6 +1381,9 @@ description_placeholder = "앱에 대한 간단한 설명을 입력하세요."
|
|||||||
logo = "앱 로고 URL"
|
logo = "앱 로고 URL"
|
||||||
logo_placeholder = "https://example.com/logo.png"
|
logo_placeholder = "https://example.com/logo.png"
|
||||||
logo_preview = "로고 미리보기"
|
logo_preview = "로고 미리보기"
|
||||||
|
logo_open = "새 탭에서 열기"
|
||||||
|
logo_preview_error_badge = "미리보기 실패"
|
||||||
|
logo_preview_empty = "미리보기"
|
||||||
name = "앱 이름"
|
name = "앱 이름"
|
||||||
name_placeholder = "예: 멋진 애플리케이션"
|
name_placeholder = "예: 멋진 애플리케이션"
|
||||||
title = "애플리케이션 정보"
|
title = "애플리케이션 정보"
|
||||||
|
|||||||
@@ -377,6 +377,10 @@ empty = ""
|
|||||||
|
|
||||||
[msg.dev.clients.general.identity]
|
[msg.dev.clients.general.identity]
|
||||||
logo_help = ""
|
logo_help = ""
|
||||||
|
logo_invalid = ""
|
||||||
|
logo_preview_loading = ""
|
||||||
|
logo_preview_ready = ""
|
||||||
|
logo_preview_failed = ""
|
||||||
subtitle = ""
|
subtitle = ""
|
||||||
|
|
||||||
[msg.dev.clients.general.redirect]
|
[msg.dev.clients.general.redirect]
|
||||||
@@ -1378,6 +1382,9 @@ description_placeholder = ""
|
|||||||
logo = ""
|
logo = ""
|
||||||
logo_placeholder = ""
|
logo_placeholder = ""
|
||||||
logo_preview = ""
|
logo_preview = ""
|
||||||
|
logo_open = ""
|
||||||
|
logo_preview_error_badge = ""
|
||||||
|
logo_preview_empty = ""
|
||||||
name = ""
|
name = ""
|
||||||
name_placeholder = ""
|
name_placeholder = ""
|
||||||
title = ""
|
title = ""
|
||||||
@@ -1545,6 +1552,7 @@ ory = ""
|
|||||||
session = ""
|
session = ""
|
||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
|
link_status_label = ""
|
||||||
last_auth_label = ""
|
last_auth_label = ""
|
||||||
status_history = ""
|
status_history = ""
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,7 @@ test.describe("DevFront role report", () => {
|
|||||||
);
|
);
|
||||||
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
await page.getByRole("button", { name: /앱 생성|Create/i }).click();
|
||||||
await createPromise;
|
await createPromise;
|
||||||
|
await expect(page).toHaveURL(/\/clients\/client-\d+\/settings$/);
|
||||||
await expect
|
await expect
|
||||||
.poll(() =>
|
.poll(() =>
|
||||||
state.auditLogs.some((item) => {
|
state.auditLogs.some((item) => {
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ export async function seedAuth(page: Page, role?: string) {
|
|||||||
"oidc.user:http://localhost:5000/oidc/:devfront",
|
"oidc.user:http://localhost:5000/oidc/:devfront",
|
||||||
JSON.stringify(mockOidcUser),
|
JSON.stringify(mockOidcUser),
|
||||||
);
|
);
|
||||||
|
window.localStorage.setItem("dev_role", injectedRole || "rp_admin");
|
||||||
window.localStorage.setItem("dev_tenant_id", "tenant-a");
|
window.localStorage.setItem("dev_tenant_id", "tenant-a");
|
||||||
},
|
},
|
||||||
{ issuedAt: nowInSeconds, injectedRole: role ?? "" },
|
{ 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) => {
|
await page.route("**/api/v1/dev/**", async (route) => {
|
||||||
const request = route.request();
|
const request = route.request();
|
||||||
const url = new URL(request.url());
|
const url = new URL(request.url());
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { defineConfig } from "vite";
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: "0.0.0.0",
|
host: "127.0.0.1",
|
||||||
port: 5173,
|
port: 5173,
|
||||||
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
allowedHosts: ["sdev.hmac.kr", "localhost", "172.16.10.176", "127.0.0.1"],
|
||||||
proxy: {
|
proxy: {
|
||||||
|
|||||||
@@ -509,9 +509,11 @@ saved_success = "Saved successfully."
|
|||||||
greeting = "Hello, {{name}}."
|
greeting = "Hello, {{name}}."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "Browser: {{value}}"
|
||||||
date = "Date: {{value}}"
|
date = "Date: {{value}}"
|
||||||
device = "Device: {{value}}"
|
device = "Device: {{value}}"
|
||||||
end = "No more items to show."
|
end = "No more items to show."
|
||||||
|
filtered_empty = "No sign-in history matches the active session filter."
|
||||||
ip = "IP address: {{value}}"
|
ip = "IP address: {{value}}"
|
||||||
load_more_error = "Could not load more history."
|
load_more_error = "Could not load more history."
|
||||||
result = "Result: {{value}}"
|
result = "Result: {{value}}"
|
||||||
@@ -549,6 +551,7 @@ client_id = "Client ID: {{id}}"
|
|||||||
client_id_missing = "No client ID available."
|
client_id_missing = "No client ID available."
|
||||||
current_status = "Current status: {{status}}"
|
current_status = "Current status: {{status}}"
|
||||||
last_auth = "Last signed in: {{value}}"
|
last_auth = "Last signed in: {{value}}"
|
||||||
|
link_status = "Link status: {{status}}"
|
||||||
link_missing = "This app does not have a launch URL configured."
|
link_missing = "This app does not have a launch URL configured."
|
||||||
link_open_error = "Could not open the app link."
|
link_open_error = "Could not open the app link."
|
||||||
render_error = "Dashboard render error: {{error}}"
|
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."
|
empty_detail = "Linked apps and their latest activity will appear here."
|
||||||
error = "Could not load linked apps."
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy."
|
copy_click = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nClick to copy."
|
||||||
copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy."
|
copy_tap = "{{label}}: {{id}}\\\\\\\\\\\\\\\\nTap to copy."
|
||||||
@@ -735,6 +752,7 @@ uppercase = "At least one uppercase letter"
|
|||||||
[msg.userfront.sections]
|
[msg.userfront.sections]
|
||||||
apps_subtitle = "Your linked apps and their latest sign-in status."
|
apps_subtitle = "Your linked apps and their latest sign-in status."
|
||||||
audit_subtitle = "Recent access history for Baron sign-in."
|
audit_subtitle = "Recent access history for Baron sign-in."
|
||||||
|
sessions_subtitle = "Your currently signed-in devices and browser sessions."
|
||||||
|
|
||||||
[msg.userfront.settings]
|
[msg.userfront.settings]
|
||||||
disabled = "Account settings are currently unavailable."
|
disabled = "Account settings are currently unavailable."
|
||||||
@@ -2040,8 +2058,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "Action"
|
||||||
app = "App"
|
app = "App"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
|
browser = "Browser"
|
||||||
date = "Date"
|
date = "Date"
|
||||||
device = "Device"
|
device = "Device"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -2065,11 +2085,23 @@ title = "Cancel consent"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "Last sign-in"
|
last_auth_label = "Last sign-in"
|
||||||
status_history = "Activity history"
|
link_status_label = "Link status"
|
||||||
|
status_history = "Link details"
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "Linked"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "Default"
|
default = "Default"
|
||||||
userfront = "Approved UserFront session ID"
|
userfront = "Approved UserFront session ID"
|
||||||
@@ -2079,7 +2111,7 @@ confirm_button = "Disconnect"
|
|||||||
title = "Disconnect app"
|
title = "Disconnect app"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "Permission (Scopes)"
|
title = "Consent scopes"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "Revoked"
|
revoked = "Revoked"
|
||||||
@@ -2204,6 +2236,7 @@ title = "Create a new password"
|
|||||||
[ui.userfront.sections]
|
[ui.userfront.sections]
|
||||||
apps = "Apps"
|
apps = "Apps"
|
||||||
audit = "Audit"
|
audit = "Audit"
|
||||||
|
sessions = "Sessions"
|
||||||
|
|
||||||
[ui.userfront.session]
|
[ui.userfront.session]
|
||||||
active = "Active session"
|
active = "Active session"
|
||||||
@@ -2251,3 +2284,11 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
action = "Go to sign-in"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "Manage My Activity"
|
||||||
|
toggle_label = "Show active sessions only"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "Toggle to view only active sessions."
|
||||||
|
|||||||
440
locales/ko.toml
440
locales/ko.toml
@@ -73,7 +73,406 @@ scope_admin = "Scoped to /admin"
|
|||||||
session_ttl = "Session TTL: 15m admin"
|
session_ttl = "Session TTL: 15m admin"
|
||||||
tenant_headers = "Tenant-aware headers"
|
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]
|
[msg.admin.api_keys.create]
|
||||||
error = "API 키 생성에 실패했습니다."
|
error = "API 키 생성에 실패했습니다."
|
||||||
@@ -509,9 +908,11 @@ saved_success = "저장이 완료되었습니다."
|
|||||||
greeting = "안녕하세요, {{name}}님"
|
greeting = "안녕하세요, {{name}}님"
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {{value}}"
|
||||||
date = "접속일자: {{value}}"
|
date = "접속일자: {{value}}"
|
||||||
device = "접속환경: {{value}}"
|
device = "접속환경: {{value}}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {{value}}"
|
ip = "접속 IP: {{value}}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {{value}}"
|
result = "인증결과: {{value}}"
|
||||||
@@ -559,6 +960,20 @@ empty = "연동된 앱이 없습니다."
|
|||||||
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||||
error = "연동 정보를 불러오지 못했습니다."
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다."
|
copy_click = "{{label}}: {{id}}\\\\n클릭하면 복사됩니다."
|
||||||
copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다."
|
copy_tap = "{{label}}: {{id}}\\\\n탭하면 복사됩니다."
|
||||||
@@ -2040,8 +2455,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "관리"
|
||||||
app = "애플리케이션"
|
app = "애플리케이션"
|
||||||
auth_method = "인증수단"
|
auth_method = "인증수단"
|
||||||
|
browser = "브라우저"
|
||||||
date = "접속일자"
|
date = "접속일자"
|
||||||
device = "접속환경"
|
device = "접속환경"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -2070,6 +2487,17 @@ status_history = "상태 이력"
|
|||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "연동됨"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "승인한 세션 ID"
|
default = "승인한 세션 ID"
|
||||||
userfront = "승인한 Userfront 세션 ID"
|
userfront = "승인한 Userfront 세션 ID"
|
||||||
@@ -2079,7 +2507,7 @@ confirm_button = "해지하기"
|
|||||||
title = "연동 해지"
|
title = "연동 해지"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "권한 (Scopes)"
|
title = "동의 범위"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "해지됨"
|
revoked = "해지됨"
|
||||||
@@ -2251,3 +2679,11 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "내 활동 관리"
|
||||||
|
toggle_label = "활성 세션만 보기"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|||||||
@@ -73,7 +73,281 @@ scope_admin = ""
|
|||||||
session_ttl = ""
|
session_ttl = ""
|
||||||
tenant_headers = ""
|
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]
|
[msg.admin.api_keys.create]
|
||||||
error = ""
|
error = ""
|
||||||
@@ -509,9 +783,11 @@ saved_success = ""
|
|||||||
greeting = ""
|
greeting = ""
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
end = ""
|
end = ""
|
||||||
|
filtered_empty = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
load_more_error = ""
|
load_more_error = ""
|
||||||
result = ""
|
result = ""
|
||||||
@@ -559,6 +835,20 @@ empty = ""
|
|||||||
empty_detail = ""
|
empty_detail = ""
|
||||||
error = ""
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = ""
|
copy_click = ""
|
||||||
copy_tap = ""
|
copy_tap = ""
|
||||||
@@ -2040,8 +2330,10 @@ dev_console = ""
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = ""
|
||||||
app = ""
|
app = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
@@ -2070,6 +2362,17 @@ status_history = ""
|
|||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = ""
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = ""
|
default = ""
|
||||||
userfront = ""
|
userfront = ""
|
||||||
@@ -2251,3 +2554,11 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = ""
|
||||||
|
toggle_label = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = ""
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ else
|
|||||||
playwright_install_desc="npx playwright install"
|
playwright_install_desc="npx playwright install"
|
||||||
fi
|
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
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
cd adminfront
|
||||||
@@ -44,7 +49,13 @@ fi
|
|||||||
set +e
|
set +e
|
||||||
(
|
(
|
||||||
cd adminfront
|
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
|
) 2>&1 | tee reports/adminfront-provision.log
|
||||||
provision_exit_code=${PIPESTATUS[0]}
|
provision_exit_code=${PIPESTATUS[0]}
|
||||||
set -e
|
set -e
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
const { spawnSync } = require("child_process");
|
||||||
|
|
||||||
const ROOT = process.cwd();
|
const ROOT = process.cwd();
|
||||||
const LOCALES_DIR = path.join(ROOT, "locales");
|
const LOCALES_DIR = path.join(ROOT, "locales");
|
||||||
@@ -84,3 +85,12 @@ const output = [
|
|||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
fs.writeFileSync(OUT_PATH, output, "utf8");
|
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;
|
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) {
|
function difference(aSet, bSet) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for (const item of aSet) {
|
for (const item of aSet) {
|
||||||
@@ -170,7 +189,7 @@ function buildReport() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const templateKeys = templateResult.keys;
|
const templateKeys = templateResult.keys;
|
||||||
const codeKeys = collectCodeKeys();
|
const codeKeys = new Set(filterCodeKeys(collectCodeKeys()));
|
||||||
|
|
||||||
const langKeyMap = new Map();
|
const langKeyMap = new Map();
|
||||||
for (const fileName of LANG_FILES) {
|
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 = {
|
type RequestCapture = {
|
||||||
loginBody?: Record<string, unknown>;
|
loginBody?: Record<string, unknown>;
|
||||||
@@ -7,15 +7,26 @@ type RequestCapture = {
|
|||||||
clientLogs: string[];
|
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> {
|
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||||
await page.waitForTimeout(300);
|
|
||||||
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
const button = page.getByRole('button', { name: 'Enable accessibility' });
|
||||||
if (await button.count()) {
|
if (await button.count()) {
|
||||||
await button.click({ force: true });
|
await button.first().evaluate((node) => {
|
||||||
const placeholder = page.locator('flt-semantics-placeholder');
|
(node as HTMLElement).click();
|
||||||
if (await placeholder.count()) {
|
});
|
||||||
await placeholder.first().click({ force: true });
|
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);
|
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);
|
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(
|
async function fillPasswordLoginForm(
|
||||||
page: Page,
|
page: Page,
|
||||||
loginId: string,
|
loginId: string,
|
||||||
@@ -128,25 +151,29 @@ async function fillPasswordLoginForm(
|
|||||||
|
|
||||||
async function submitPasswordLogin(page: Page): Promise<void> {
|
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||||
if (isMobileProject(page)) {
|
if (isMobileProject(page)) {
|
||||||
|
await enableFlutterAccessibility(page);
|
||||||
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
await page.getByRole('button', { name: '로그인' }).click({ force: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const coords = coordsFor(page);
|
await page.keyboard.press('Enter');
|
||||||
await page.locator('flt-glass-pane').click({
|
|
||||||
position: { x: coords.signinSubmitX, y: coords.signinSubmitY },
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
|
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)) {
|
if (isMobileProject(page)) {
|
||||||
await enableFlutterAccessibility(page);
|
await page.getByRole('textbox', { name: resetNewPasswordName }).fill(password);
|
||||||
await page
|
await page.getByRole('textbox', { name: resetConfirmPasswordName }).fill(password);
|
||||||
.getByRole('textbox', { name: /^새 비밀번호$/ })
|
|
||||||
.fill(password);
|
|
||||||
await page
|
|
||||||
.getByRole('textbox', { name: /^새 비밀번호 확인$/ })
|
|
||||||
.fill(password);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const coords = coordsFor(page);
|
const coords = coordsFor(page);
|
||||||
@@ -160,8 +187,13 @@ async function fillResetPasswordForm(page: Page, password: string): Promise<void
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitResetPassword(page: Page): 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)) {
|
if (isMobileProject(page)) {
|
||||||
await page.getByRole('button', { name: '비밀번호 변경' }).click({ force: true });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const coords = coordsFor(page);
|
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}."
|
greeting = "Hello, {name}."
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "Browser: {value}"
|
||||||
date = "Date: {value}"
|
date = "Date: {value}"
|
||||||
device = "Device: {value}"
|
device = "Device: {value}"
|
||||||
end = "No more items to show."
|
end = "No more items to show."
|
||||||
|
filtered_empty = "No sign-in history matches the active session filter."
|
||||||
ip = "IP address: {value}"
|
ip = "IP address: {value}"
|
||||||
load_more_error = "Could not load more history."
|
load_more_error = "Could not load more history."
|
||||||
result = "Result: {value}"
|
result = "Result: {value}"
|
||||||
@@ -84,6 +86,7 @@ client_id = "Client ID: {id}"
|
|||||||
client_id_missing = "No client ID available."
|
client_id_missing = "No client ID available."
|
||||||
current_status = "Current status: {status}"
|
current_status = "Current status: {status}"
|
||||||
last_auth = "Last signed in: {value}"
|
last_auth = "Last signed in: {value}"
|
||||||
|
link_status = "Link status: {status}"
|
||||||
link_missing = "This app does not have a launch URL configured."
|
link_missing = "This app does not have a launch URL configured."
|
||||||
link_open_error = "Could not open the app link."
|
link_open_error = "Could not open the app link."
|
||||||
render_error = "Dashboard render error: {error}"
|
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."
|
empty_detail = "Linked apps and their latest activity will appear here."
|
||||||
error = "Could not load linked apps."
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
|
copy_click = "{label}: {id}\\\\\\\\\\\\\\\\nClick to copy."
|
||||||
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
|
copy_tap = "{label}: {id}\\\\\\\\\\\\\\\\nTap to copy."
|
||||||
@@ -270,6 +287,7 @@ uppercase = "At least one uppercase letter"
|
|||||||
[msg.userfront.sections]
|
[msg.userfront.sections]
|
||||||
apps_subtitle = "Your linked apps and their latest sign-in status."
|
apps_subtitle = "Your linked apps and their latest sign-in status."
|
||||||
audit_subtitle = "Recent access history for Baron sign-in."
|
audit_subtitle = "Recent access history for Baron sign-in."
|
||||||
|
sessions_subtitle = "Your currently signed-in devices and browser sessions."
|
||||||
|
|
||||||
[msg.userfront.settings]
|
[msg.userfront.settings]
|
||||||
disabled = "Account settings are currently unavailable."
|
disabled = "Account settings are currently unavailable."
|
||||||
@@ -420,8 +438,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "Action"
|
||||||
app = "App"
|
app = "App"
|
||||||
auth_method = "Auth Method"
|
auth_method = "Auth Method"
|
||||||
|
browser = "Browser"
|
||||||
date = "Date"
|
date = "Date"
|
||||||
device = "Device"
|
device = "Device"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -445,11 +465,23 @@ title = "Cancel consent"
|
|||||||
|
|
||||||
[ui.userfront.dashboard]
|
[ui.userfront.dashboard]
|
||||||
last_auth_label = "Last sign-in"
|
last_auth_label = "Last sign-in"
|
||||||
status_history = "Activity history"
|
link_status_label = "Link status"
|
||||||
|
status_history = "Link details"
|
||||||
|
|
||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "Linked"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "Default"
|
default = "Default"
|
||||||
userfront = "Approved UserFront session ID"
|
userfront = "Approved UserFront session ID"
|
||||||
@@ -459,7 +491,7 @@ confirm_button = "Disconnect"
|
|||||||
title = "Disconnect app"
|
title = "Disconnect app"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "Permission (Scopes)"
|
title = "Consent scopes"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "Revoked"
|
revoked = "Revoked"
|
||||||
@@ -584,6 +616,7 @@ title = "Create a new password"
|
|||||||
[ui.userfront.sections]
|
[ui.userfront.sections]
|
||||||
apps = "Apps"
|
apps = "Apps"
|
||||||
audit = "Audit"
|
audit = "Audit"
|
||||||
|
sessions = "Sessions"
|
||||||
|
|
||||||
[ui.userfront.session]
|
[ui.userfront.session]
|
||||||
active = "Active session"
|
active = "Active session"
|
||||||
@@ -631,3 +664,11 @@ verify = "Verification"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "Go to sign-in"
|
action = "Go to sign-in"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "Manage My Activity"
|
||||||
|
toggle_label = "Show active sessions only"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "Toggle to view only active sessions."
|
||||||
|
|||||||
@@ -40,13 +40,223 @@ verify_code_failed = "인증 실패: {error}"
|
|||||||
[err.userfront.session]
|
[err.userfront.session]
|
||||||
missing = "활성 세션이 없습니다."
|
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]
|
[msg.userfront]
|
||||||
greeting = "안녕하세요, {name}님"
|
greeting = "안녕하세요, {name}님"
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = "브라우저: {value}"
|
||||||
date = "접속일자: {value}"
|
date = "접속일자: {value}"
|
||||||
device = "접속환경: {value}"
|
device = "접속환경: {value}"
|
||||||
end = "더 이상 항목이 없습니다."
|
end = "더 이상 항목이 없습니다."
|
||||||
|
filtered_empty = "활성 세션으로 필터링된 접속 이력이 없습니다."
|
||||||
ip = "접속 IP: {value}"
|
ip = "접속 IP: {value}"
|
||||||
load_more_error = "더 불러오지 못했습니다."
|
load_more_error = "더 불러오지 못했습니다."
|
||||||
result = "인증결과: {value}"
|
result = "인증결과: {value}"
|
||||||
@@ -94,6 +304,20 @@ empty = "연동된 앱이 없습니다."
|
|||||||
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
empty_detail = "앱을 연동하면 최근 활동과 상태가 표시됩니다."
|
||||||
error = "연동 정보를 불러오지 못했습니다."
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
|
copy_click = "{label}: {id}\\\\n클릭하면 복사됩니다."
|
||||||
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
|
copy_tap = "{label}: {id}\\\\n탭하면 복사됩니다."
|
||||||
@@ -420,8 +644,10 @@ dev_console = "Dev Console"
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = "관리"
|
||||||
app = "애플리케이션"
|
app = "애플리케이션"
|
||||||
auth_method = "인증수단"
|
auth_method = "인증수단"
|
||||||
|
browser = "브라우저"
|
||||||
date = "접속일자"
|
date = "접속일자"
|
||||||
device = "접속환경"
|
device = "접속환경"
|
||||||
ip = "IP"
|
ip = "IP"
|
||||||
@@ -450,6 +676,17 @@ status_history = "상태 이력"
|
|||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = "연동됨"
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = "승인한 세션 ID"
|
default = "승인한 세션 ID"
|
||||||
userfront = "승인한 Userfront 세션 ID"
|
userfront = "승인한 Userfront 세션 ID"
|
||||||
@@ -459,7 +696,7 @@ confirm_button = "해지하기"
|
|||||||
title = "연동 해지"
|
title = "연동 해지"
|
||||||
|
|
||||||
[ui.userfront.dashboard.scopes]
|
[ui.userfront.dashboard.scopes]
|
||||||
title = "권한 (Scopes)"
|
title = "동의 범위"
|
||||||
|
|
||||||
[ui.userfront.dashboard.status]
|
[ui.userfront.dashboard.status]
|
||||||
revoked = "해지됨"
|
revoked = "해지됨"
|
||||||
@@ -631,3 +868,11 @@ verify = "본인인증"
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = "로그인하기"
|
action = "로그인하기"
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = "내 활동 관리"
|
||||||
|
toggle_label = "활성 세션만 보기"
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = "활성화된 세션만 보려면 토글을 켜주세요."
|
||||||
|
|||||||
@@ -40,13 +40,195 @@ verify_code_failed = ""
|
|||||||
[err.userfront.session]
|
[err.userfront.session]
|
||||||
missing = ""
|
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]
|
[msg.userfront]
|
||||||
greeting = ""
|
greeting = ""
|
||||||
|
|
||||||
[msg.userfront.audit]
|
[msg.userfront.audit]
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
end = ""
|
end = ""
|
||||||
|
filtered_empty = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
load_more_error = ""
|
load_more_error = ""
|
||||||
result = ""
|
result = ""
|
||||||
@@ -94,6 +276,20 @@ empty = ""
|
|||||||
empty_detail = ""
|
empty_detail = ""
|
||||||
error = ""
|
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]
|
[msg.userfront.dashboard.approved_session]
|
||||||
copy_click = ""
|
copy_click = ""
|
||||||
copy_tap = ""
|
copy_tap = ""
|
||||||
@@ -420,8 +616,10 @@ dev_console = ""
|
|||||||
[ui.userfront.audit]
|
[ui.userfront.audit]
|
||||||
|
|
||||||
[ui.userfront.audit.table]
|
[ui.userfront.audit.table]
|
||||||
|
action = ""
|
||||||
app = ""
|
app = ""
|
||||||
auth_method = ""
|
auth_method = ""
|
||||||
|
browser = ""
|
||||||
date = ""
|
date = ""
|
||||||
device = ""
|
device = ""
|
||||||
ip = ""
|
ip = ""
|
||||||
@@ -450,6 +648,17 @@ status_history = ""
|
|||||||
[ui.userfront.dashboard.activity]
|
[ui.userfront.dashboard.activity]
|
||||||
linked = ""
|
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]
|
[ui.userfront.dashboard.approved_session]
|
||||||
default = ""
|
default = ""
|
||||||
userfront = ""
|
userfront = ""
|
||||||
@@ -631,3 +840,11 @@ verify = ""
|
|||||||
|
|
||||||
[ui.userfront.signup.success]
|
[ui.userfront.signup.success]
|
||||||
action = ""
|
action = ""
|
||||||
|
|
||||||
|
|
||||||
|
[ui.userfront.audit.filter]
|
||||||
|
title = ""
|
||||||
|
toggle_label = ""
|
||||||
|
|
||||||
|
[msg.userfront.audit.filter]
|
||||||
|
description = ""
|
||||||
|
|||||||
@@ -1,22 +1,59 @@
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
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 {
|
class TomlAssetLoader extends AssetLoader {
|
||||||
const TomlAssetLoader();
|
const TomlAssetLoader();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> load(String path, Locale locale) async {
|
Future<Map<String, dynamic>> load(String path, Locale locale) async {
|
||||||
final assetPath = '$path/${locale.languageCode}.toml';
|
final languageCode = locale.languageCode.toLowerCase();
|
||||||
try {
|
final source = switch (languageCode) {
|
||||||
final content = await rootBundle.loadString(assetPath);
|
'ko' => koStrings,
|
||||||
final document = TomlDocument.parse(content);
|
'en' => enStrings,
|
||||||
return document.toMap();
|
_ => enStrings,
|
||||||
} catch (e) {
|
};
|
||||||
// 로딩 실패 시 빈 맵을 반환해 렌더링을 지속합니다.
|
return _expandFlatTranslations(source);
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
static Future<Map<String, dynamic>> verifyLoginShortCode(
|
||||||
String shortCode, {
|
String shortCode, {
|
||||||
bool verifyOnly = false,
|
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 {
|
Future<void> _fetchConsentInfo() async {
|
||||||
try {
|
try {
|
||||||
final info = await AuthProxyService.getConsentInfo(
|
final info = await AuthProxyService.getConsentInfo(
|
||||||
@@ -271,7 +305,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.consent.description'),
|
_renderConsentText('msg.userfront.consent.description'),
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -318,11 +352,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
tr(
|
_renderClientIdLabel(clientId),
|
||||||
'msg.userfront.consent.client_id',
|
|
||||||
fallback: 'Client ID: {{id}}',
|
|
||||||
params: {'id': clientId},
|
|
||||||
),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: Colors.grey[500],
|
color: Colors.grey[500],
|
||||||
@@ -349,11 +379,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
tr(
|
_renderScopeCountLabel(requestedScopes.length),
|
||||||
'msg.userfront.consent.scope_count',
|
|
||||||
fallback: 'Total {{count}}',
|
|
||||||
params: {'count': '${requestedScopes.length}'},
|
|
||||||
),
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Theme.of(context).primaryColor,
|
color: Theme.of(context).primaryColor,
|
||||||
@@ -371,7 +397,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
|||||||
|
|
||||||
return CheckboxListTile(
|
return CheckboxListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
scope, // 스코프 키 (예: openid)
|
_scopeDisplayLabel(scope),
|
||||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Text(description),
|
subtitle: Text(description),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../../../core/constants/error_whitelist.dart';
|
import '../../../core/constants/error_whitelist.dart';
|
||||||
import '../../../core/i18n/locale_utils.dart';
|
import '../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../core/services/auth_proxy_service.dart';
|
import '../../../core/services/auth_proxy_service.dart';
|
||||||
|
import '../../../core/widgets/theme_toggle_button.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
|
|
||||||
class ErrorScreen extends StatelessWidget {
|
class ErrorScreen extends StatelessWidget {
|
||||||
@@ -22,6 +23,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
|
||||||
final normalizedCode = (errorCode ?? '').trim();
|
final normalizedCode = (errorCode ?? '').trim();
|
||||||
final hasCode = normalizedCode.isNotEmpty;
|
final hasCode = normalizedCode.isNotEmpty;
|
||||||
@@ -62,7 +64,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
: tr('msg.userfront.error.detail_request')));
|
: tr('msg.userfront.error.detail_request')));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F8FA),
|
backgroundColor: colorScheme.surfaceContainerLowest,
|
||||||
body: Center(
|
body: Center(
|
||||||
child: ConstrainedBox(
|
child: ConstrainedBox(
|
||||||
constraints: const BoxConstraints(maxWidth: 560),
|
constraints: const BoxConstraints(maxWidth: 560),
|
||||||
@@ -71,7 +73,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
side: BorderSide(color: colorScheme.outlineVariant),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
padding: const EdgeInsets.fromLTRB(28, 28, 28, 24),
|
||||||
@@ -79,18 +81,25 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
title,
|
children: [
|
||||||
style: theme.textTheme.titleLarge?.copyWith(
|
Expanded(
|
||||||
fontWeight: FontWeight.w700,
|
child: Text(
|
||||||
color: const Color(0xFF111827),
|
title,
|
||||||
),
|
style: theme.textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const ThemeToggleButton(compact: true),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
detail,
|
detail,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
color: const Color(0xFF4B5563),
|
color: colorScheme.onSurfaceVariant,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,7 +107,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.error.type', params: {'type': errorType}),
|
tr('msg.userfront.error.type', params: {'type': errorType}),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: const Color(0xFF6B7280),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (errorId != null && errorId!.isNotEmpty) ...[
|
if (errorId != null && errorId!.isNotEmpty) ...[
|
||||||
@@ -106,7 +115,7 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
tr('msg.userfront.error.id', params: {'id': errorId!}),
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: const Color(0xFF6B7280),
|
color: colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -118,8 +127,8 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () => context.go('/login'),
|
onPressed: () => context.go('/login'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF111827),
|
backgroundColor: colorScheme.primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: colorScheme.onPrimary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -134,12 +143,12 @@ class ErrorScreen extends StatelessWidget {
|
|||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
context.go(buildLocalizedHomePath(Uri.base)),
|
context.go(buildLocalizedHomePath(Uri.base)),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: const Color(0xFF111827),
|
foregroundColor: colorScheme.onSurface,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
side: BorderSide(color: colorScheme.outline),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
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;
|
Map<String, dynamic>? _policy;
|
||||||
bool _isPolicyLoading = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -123,16 +135,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
|||||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||||
|
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.reset.policy.min_length',
|
'msg.userfront.reset.policy.min_length',
|
||||||
params: {'count': '$minLength'},
|
values: {'count': '$minLength'},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (minTypes > 0) {
|
if (minTypes > 0) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.reset.policy.min_types',
|
'msg.userfront.reset.policy.min_types',
|
||||||
params: {'count': '$minTypes'},
|
values: {'count': '$minTypes'},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,6 +69,18 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
Timer? _phoneTimer;
|
Timer? _phoneTimer;
|
||||||
int _phoneSeconds = 0;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -1663,16 +1675,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
|||||||
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
||||||
|
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.min_length',
|
'msg.userfront.signup.policy.min_length',
|
||||||
params: {'count': minLength.toString()},
|
values: {'count': minLength.toString()},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (minTypes > 0) {
|
if (minTypes > 0) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.min_types',
|
'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'));
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return tr(
|
return _renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.summary',
|
'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 name;
|
||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
|
final String initUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -105,6 +106,7 @@ class LinkedRp {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
|
required this.initUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
this.lastAuthenticatedAt,
|
this.lastAuthenticatedAt,
|
||||||
@@ -126,6 +128,7 @@ class LinkedRp {
|
|||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? '',
|
status: json['status']?.toString() ?? '',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
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 name;
|
||||||
final String logo;
|
final String logo;
|
||||||
final String url;
|
final String url;
|
||||||
|
final String initUrl;
|
||||||
final String status;
|
final String status;
|
||||||
final List<String> scopes;
|
final List<String> scopes;
|
||||||
final DateTime? lastAuthenticatedAt;
|
final DateTime? lastAuthenticatedAt;
|
||||||
@@ -19,6 +20,7 @@ class LinkedRp {
|
|||||||
required this.name,
|
required this.name,
|
||||||
required this.logo,
|
required this.logo,
|
||||||
required this.url,
|
required this.url,
|
||||||
|
required this.initUrl,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.scopes,
|
required this.scopes,
|
||||||
required this.lastAuthenticatedAt,
|
required this.lastAuthenticatedAt,
|
||||||
@@ -40,6 +42,7 @@ class LinkedRp {
|
|||||||
name: json['name']?.toString() ?? '',
|
name: json['name']?.toString() ?? '',
|
||||||
logo: json['logo']?.toString() ?? '',
|
logo: json['logo']?.toString() ?? '',
|
||||||
url: json['url']?.toString() ?? '',
|
url: json['url']?.toString() ?? '',
|
||||||
|
initUrl: json['init_url']?.toString() ?? '',
|
||||||
status: json['status']?.toString() ?? 'unknown',
|
status: json['status']?.toString() ?? 'unknown',
|
||||||
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
scopes: (json['scopes'] as List?)?.whereType<String>().toList() ?? [],
|
||||||
lastAuthenticatedAt: parsedLastAuth,
|
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:go_router/go_router.dart';
|
||||||
import 'package:logging/logging.dart';
|
import 'package:logging/logging.dart';
|
||||||
import 'package:userfront/i18n.dart';
|
import 'package:userfront/i18n.dart';
|
||||||
import '../../../../core/notifiers/auth_notifier.dart';
|
|
||||||
import '../../../../core/i18n/locale_utils.dart';
|
import '../../../../core/i18n/locale_utils.dart';
|
||||||
import '../../../../core/services/auth_proxy_service.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/layout_breakpoints.dart';
|
||||||
import '../../../../core/ui/toast_service.dart';
|
import '../../../../core/ui/toast_service.dart';
|
||||||
import '../../../../core/widgets/language_selector.dart';
|
import '../../../../core/widgets/language_selector.dart';
|
||||||
|
import '../../../../core/widgets/theme_toggle_button.dart';
|
||||||
import '../../data/models/user_profile_model.dart';
|
import '../../data/models/user_profile_model.dart';
|
||||||
import '../../domain/notifiers/profile_notifier.dart';
|
import '../../domain/notifiers/profile_notifier.dart';
|
||||||
|
|
||||||
@@ -21,10 +21,6 @@ class ProfilePage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
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');
|
static final _log = Logger('ProfilePage');
|
||||||
|
|
||||||
UserProfile? _cachedProfile;
|
UserProfile? _cachedProfile;
|
||||||
@@ -55,9 +51,27 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
bool _showCurrentPassword = false;
|
bool _showCurrentPassword = false;
|
||||||
bool _showNewPassword = false;
|
bool _showNewPassword = false;
|
||||||
bool _showConfirmPassword = false;
|
bool _showConfirmPassword = false;
|
||||||
|
bool _isDesktopSideMenuOpen = true;
|
||||||
Map<String, dynamic>? _passwordPolicy;
|
Map<String, dynamic>? _passwordPolicy;
|
||||||
bool _isPasswordPolicyLoading = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -99,16 +113,16 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
final requiresSymbol = _passwordPolicy?['nonAlphanumeric'] ?? true;
|
||||||
|
|
||||||
final parts = <String>[
|
final parts = <String>[
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.min_length',
|
'msg.userfront.signup.policy.min_length',
|
||||||
params: {'count': '$minLength'},
|
values: {'count': '$minLength'},
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
if (minTypes > 0) {
|
if (minTypes > 0) {
|
||||||
parts.add(
|
parts.add(
|
||||||
tr(
|
_renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.min_types',
|
'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'));
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
||||||
}
|
}
|
||||||
|
|
||||||
return tr(
|
return _renderTranslatedText(
|
||||||
'msg.userfront.signup.policy.summary',
|
'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 {
|
Future<void> _logout() async {
|
||||||
AuthTokenStore.clear();
|
await LogoutService().logout();
|
||||||
AuthNotifier.instance.notify();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _ensureControllers(UserProfile profile) {
|
void _ensureControllers(UserProfile profile) {
|
||||||
@@ -605,7 +618,14 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
),
|
),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.only(bottom: 16),
|
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: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -644,7 +664,7 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -690,8 +710,12 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.profile.greeting', params: {'name': name}),
|
_renderTranslatedText(
|
||||||
style: const TextStyle(
|
'msg.userfront.profile.greeting',
|
||||||
|
fallback: 'Hello, {{name}}.',
|
||||||
|
values: {'name': name},
|
||||||
|
),
|
||||||
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: _ink,
|
color: _ink,
|
||||||
@@ -982,12 +1006,17 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
tr('msg.userfront.profile.password.subtitle'),
|
tr('msg.userfront.profile.password.subtitle'),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280)),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
_buildPasswordPolicyDescription(),
|
_buildPasswordPolicyDescription(),
|
||||||
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 12),
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextField(
|
TextField(
|
||||||
@@ -1217,14 +1246,35 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: _subtle,
|
backgroundColor: _subtle,
|
||||||
appBar: AppBar(
|
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(
|
title: Text(
|
||||||
tr('ui.userfront.app_title'),
|
tr('ui.userfront.app_title'),
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
elevation: 0,
|
|
||||||
backgroundColor: _surface,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
actions: [
|
actions: [
|
||||||
|
const ThemeToggleButton(compact: true),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.home_outlined),
|
icon: const Icon(Icons.home_outlined),
|
||||||
tooltip: tr('ui.userfront.nav.dashboard'),
|
tooltip: tr('ui.userfront.nav.dashboard'),
|
||||||
@@ -1245,7 +1295,8 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
|||||||
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
drawer: isWide ? null : Drawer(child: _buildSideMenu(context)),
|
||||||
body: Row(
|
body: Row(
|
||||||
children: [
|
children: [
|
||||||
if (isWide) SizedBox(width: 240, child: _buildSideMenu(context)),
|
if (isWide && _isDesktopSideMenuOpen)
|
||||||
|
SizedBox(width: 240, child: _buildSideMenu(context)),
|
||||||
Expanded(child: _buildContent(profile, isUpdating)),
|
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/null_check_recovery.dart';
|
||||||
import 'core/services/web_window.dart';
|
import 'core/services/web_window.dart';
|
||||||
import 'core/notifiers/auth_notifier.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_gate.dart';
|
||||||
import 'core/i18n/locale_registry.dart';
|
import 'core/i18n/locale_registry.dart';
|
||||||
import 'core/i18n/locale_utils.dart';
|
import 'core/i18n/locale_utils.dart';
|
||||||
@@ -106,6 +109,8 @@ void main() async {
|
|||||||
|
|
||||||
// 0. Initialize Logger
|
// 0. Initialize Logger
|
||||||
LoggerService.init();
|
LoggerService.init();
|
||||||
|
await ThemeController.app.restore();
|
||||||
|
await ThemeController.auth.restore();
|
||||||
|
|
||||||
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
// 폰트를 먼저 로딩해서 렌더링 깨짐(FOIT/FOUT) 최소화
|
||||||
await _loadBundledFonts();
|
await _loadBundledFonts();
|
||||||
@@ -177,12 +182,18 @@ final _router = GoRouter(
|
|||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'dashboard',
|
path: 'dashboard',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return const DashboardScreen();
|
return ScopedTheme(
|
||||||
|
controller: ThemeController.app,
|
||||||
|
child: const DashboardScreen(),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'profile',
|
path: 'profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.app,
|
||||||
|
child: const ProfilePage(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'signin',
|
path: 'signin',
|
||||||
@@ -192,10 +203,13 @@ final _router = GoRouter(
|
|||||||
final redirectUrl =
|
final redirectUrl =
|
||||||
state.uri.queryParameters['redirect_uri'] ??
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
state.uri.queryParameters['redirect_url'];
|
state.uri.queryParameters['redirect_url'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
loginChallenge: loginChallenge,
|
child: LoginScreen(
|
||||||
redirectUrl: redirectUrl,
|
key: state.pageKey,
|
||||||
|
loginChallenge: loginChallenge,
|
||||||
|
redirectUrl: redirectUrl,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -208,10 +222,13 @@ final _router = GoRouter(
|
|||||||
final redirectUrl =
|
final redirectUrl =
|
||||||
state.uri.queryParameters['redirect_uri'] ??
|
state.uri.queryParameters['redirect_uri'] ??
|
||||||
state.uri.queryParameters['redirect_url'];
|
state.uri.queryParameters['redirect_url'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
loginChallenge: loginChallenge,
|
child: LoginScreen(
|
||||||
redirectUrl: redirectUrl,
|
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(
|
GoRoute(
|
||||||
path: 'signup',
|
path: 'signup',
|
||||||
builder: (context, state) => const SignupScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const SignupScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'registration',
|
path: 'registration',
|
||||||
builder: (context, state) => const SignupScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const SignupScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verify',
|
path: 'verify',
|
||||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verify/:token',
|
path: 'verify/:token',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final token = state.pathParameters['token'];
|
final token = state.pathParameters['token'];
|
||||||
return LoginScreen(
|
return ScopedTheme(
|
||||||
key: state.pageKey,
|
controller: ThemeController.auth,
|
||||||
verificationToken: token,
|
child: LoginScreen(
|
||||||
|
key: state.pageKey,
|
||||||
|
verificationToken: token,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'verification',
|
path: 'verification',
|
||||||
builder: (context, state) => LoginScreen(key: state.pageKey),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'l/:shortCode',
|
path: 'l/:shortCode',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return LoginScreen(key: state.pageKey);
|
return ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: LoginScreen(key: state.pageKey),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'forgot-password',
|
path: 'forgot-password',
|
||||||
builder: (context, state) => const ForgotPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ForgotPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'recovery',
|
path: 'recovery',
|
||||||
builder: (context, state) => const ForgotPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ForgotPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'reset-password',
|
path: 'reset-password',
|
||||||
builder: (context, state) => const ResetPasswordScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const ResetPasswordScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'error',
|
path: 'error',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final params = state.uri.queryParameters;
|
final params = state.uri.queryParameters;
|
||||||
return ErrorScreen(
|
return ScopedTheme(
|
||||||
errorId: params['id'],
|
controller: ThemeController.auth,
|
||||||
errorCode: params['error'],
|
child: ErrorScreen(
|
||||||
description: params['error_description'] ?? params['message'],
|
errorId: params['id'],
|
||||||
|
errorCode: params['error'],
|
||||||
|
description:
|
||||||
|
params['error_description'] ?? params['message'],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
builder: (context, state) => ErrorScreen(
|
builder: (context, state) => ScopedTheme(
|
||||||
errorCode: 'settings_disabled',
|
controller: ThemeController.auth,
|
||||||
description: tr('msg.userfront.settings.disabled'),
|
child: ErrorScreen(
|
||||||
|
errorCode: 'settings_disabled',
|
||||||
|
description: tr('msg.userfront.settings.disabled'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'approve',
|
path: 'approve',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => ScopedTheme(
|
||||||
ApproveQrScreen(pendingRef: state.uri.queryParameters['ref']),
|
controller: ThemeController.auth,
|
||||||
|
child: ApproveQrScreen(
|
||||||
|
pendingRef: state.uri.queryParameters['ref'],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'ql/:ref',
|
path: 'ql/:ref',
|
||||||
builder: (context, state) =>
|
builder: (context, state) => ScopedTheme(
|
||||||
ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
controller: ThemeController.auth,
|
||||||
|
child: ApproveQrScreen(pendingRef: state.pathParameters['ref']),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'scan',
|
path: 'scan',
|
||||||
builder: (context, state) => const QRScanScreen(),
|
builder: (context, state) => ScopedTheme(
|
||||||
|
controller: ThemeController.auth,
|
||||||
|
child: const QRScanScreen(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: 'admin/users',
|
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()],
|
children: [if (child != null) child, const ToastViewport()],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
theme: ThemeData(
|
theme: buildLightTheme(),
|
||||||
colorScheme: ColorScheme.fromSeed(
|
darkTheme: buildDarkTheme(),
|
||||||
seedColor: const Color(0xFF1A1F2C), // Dark Navy/Black base
|
themeMode: ThemeMode.light,
|
||||||
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(),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
routerConfig: _router,
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
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:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -388,6 +396,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.9.1"
|
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:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -485,7 +501,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.2.0"
|
||||||
shared_preferences:
|
shared_preferences:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: shared_preferences
|
name: shared_preferences
|
||||||
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
|
||||||
@@ -753,6 +769,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.5"
|
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:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -825,6 +865,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.5.0"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ dependencies:
|
|||||||
go_router: ^17.0.1
|
go_router: ^17.0.1
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
flutter_dotenv: ^6.0.0
|
flutter_dotenv: ^6.0.0
|
||||||
|
flutter_svg: ^2.2.1
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
logging: ^1.2.0
|
logging: ^1.2.0
|
||||||
logger: ^2.0.0
|
logger: ^2.0.0
|
||||||
@@ -48,6 +49,7 @@ dependencies:
|
|||||||
easy_localization: ^3.0.7
|
easy_localization: ^3.0.7
|
||||||
toml: ^0.15.0
|
toml: ^0.15.0
|
||||||
web: ^1.1.0
|
web: ^1.1.0
|
||||||
|
shared_preferences: ^2.5.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
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