1
0
forked from baron/baron-sso

Merge pull request 'feature/df-i18n-ui' (#348) from feature/df-i18n-ui into dev

Reviewed-on: baron/baron-sso#348
This commit is contained in:
2026-02-27 15:02:24 +09:00
18 changed files with 415 additions and 150 deletions

View File

@@ -265,7 +265,9 @@ code-check-userfront-e2e-tests:
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && npm test) || status=$$?; \
port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \
echo "==> userfront-e2e using PORT=$$port"; \
(cd "$$tmp_dir/userfront-e2e" && PORT=$$port npm test) || status=$$?; \
fi; \
[ -d "$$tmp_dir/userfront-e2e/playwright-report" ] && cp -R "$$tmp_dir/userfront-e2e/playwright-report" reports/userfront-e2e/ || true; \
[ -d "$$tmp_dir/userfront-e2e/test-results" ] && cp -R "$$tmp_dir/userfront-e2e/test-results" reports/userfront-e2e/ || true; \

View File

@@ -211,11 +211,11 @@ function AppLayout() {
</button>
</div>
<div className="hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block">
<p>{t("msg.dev.sidebar.notice", "개발자 전용 콘솔입니다.")}</p>
<p>{t("msg.dev.sidebar.notice", "Developer Console")}</p>
<p>
{t(
"msg.dev.sidebar.notice_detail",
"클라이언트 애플리케이션 등록 및 관리를 수행할 수 있습니다.",
"Register and manage client applications.",
)}
</p>
</div>
@@ -299,11 +299,17 @@ function AppLayout() {
disabled={isRefreshingSession}
>
{isRefreshingSession
? t(
"ui.dev.session.refreshing",
"세션 만료 시간 갱신 중...",
)
: t("ui.dev.session.refresh", "세션 만료 시간 갱신")}
? t("ui.dev.session.refreshing", "Refreshing...")
: t("ui.dev.session.refresh", "Refresh session expiry")}
</button>
<button
type="button"
role="menuitem"
className="mt-2 w-full flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-left text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive"
onClick={handleLogout}
>
<LogOut size={16} />
<span>{t("ui.dev.nav.logout", "Logout")}</span>
</button>
</div>
) : null}

View File

@@ -17,6 +17,7 @@ const badgeVariants = cva(
"border-transparent bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300",
warning:
"border-transparent bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200",
info: "border-transparent bg-blue-500 text-white hover:bg-blue-500/90",
},
},
defaultVariants: {

View File

@@ -34,8 +34,8 @@ function ClientConsentsPage() {
const clientId = params.id ?? "";
const [subjectInput, setSubjectInput] = useState("");
const [subject, setSubject] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [scopeFilter, setScopeFilter] = useState("all");
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [scopeFilter, setScopeFilter] = useState<string[]>([]);
const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false);
const { data: clientData } = useQuery({
@@ -49,8 +49,8 @@ function ClientConsentsPage() {
error,
refetch,
} = useQuery({
queryKey: ["consents", clientId, subject, statusFilter],
queryFn: () => fetchConsents(subject, clientId, statusFilter),
queryKey: ["consents", clientId, subject],
queryFn: () => fetchConsents(subject, clientId, "all"),
enabled: clientId.length > 0,
});
const revokeMutation = useMutation({
@@ -77,7 +77,12 @@ function ClientConsentsPage() {
const rows = consentsData?.items ?? [];
const allScopes = Array.from(new Set(rows.flatMap((r) => r.grantedScopes)));
const filteredRows = rows.filter((row) => {
return scopeFilter === "all" || row.grantedScopes.includes(scopeFilter);
const matchStatus =
statusFilter.length === 0 || statusFilter.includes(row.status);
const matchScope =
scopeFilter.length === 0 ||
scopeFilter.some((s) => row.grantedScopes.includes(s));
return matchStatus && matchScope;
});
const handleExportCSV = () => {
@@ -130,6 +135,30 @@ function ClientConsentsPage() {
document.body.removeChild(link);
};
const handleStatusFilterChange = (status: string, checked: boolean) => {
if (checked) {
setStatusFilter((prev) => [...prev, status]);
} else {
setStatusFilter((prev) => prev.filter((s) => s !== status));
}
};
const handleScopeFilterChange = (scope: string, checked: boolean) => {
if (checked) {
setScopeFilter((prev) => [...prev, scope]);
} else {
setScopeFilter((prev) => prev.filter((s) => s !== scope));
}
};
const handleAllScopesChange = (checked: boolean) => {
if (checked) {
setScopeFilter(allScopes);
} else {
setScopeFilter([]);
}
};
return (
<div className="space-y-8">
<header className="space-y-4">
@@ -175,7 +204,7 @@ function ClientConsentsPage() {
<div className="flex items-center gap-3">
<Badge
variant={
clientData?.client?.status === "active" ? "success" : "muted"
clientData?.client?.status === "active" ? "info" : "muted"
}
>
{clientData?.client?.status === "active"
@@ -252,59 +281,90 @@ function ClientConsentsPage() {
</div>
{isAdvancedFilterOpen && (
<div className="flex flex-wrap items-center gap-6 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
<div className="flex flex-col gap-4 rounded-lg bg-secondary/30 p-4 border border-border/40 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.status_label", "Status:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.consents.status_all", "All Statuses")}
</option>
<option value="active">
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("active")}
onChange={(e) =>
handleStatusFilterChange("active", e.target.checked)
}
/>
{t("ui.common.status.active", "Active")}
</option>
<option value="revoked">
</label>
<label className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={statusFilter.includes("revoked")}
onChange={(e) =>
handleStatusFilterChange("revoked", e.target.checked)
}
/>
{t("ui.dev.clients.consents.status_revoked", "Revoked")}
</option>
</select>
</label>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground whitespace-nowrap">
<div className="flex flex-col gap-2">
<span className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.dev.clients.consents.scope_label", "Scope:")}
</span>
<select
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 min-w-[140px]"
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value)}
>
<option value="all">
{t("ui.dev.clients.consents.scope_all", "All Scopes")}
</option>
<div className="flex flex-wrap gap-4">
{allScopes.length > 0 && (
<label className="flex items-center gap-2 text-sm cursor-pointer font-bold text-primary hover:opacity-80">
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={
scopeFilter.length === allScopes.length &&
allScopes.length > 0
}
onChange={(e) =>
handleAllScopesChange(e.target.checked)
}
/>
ALL
</label>
)}
{allScopes.map((scope) => (
<option key={scope} value={scope}>
<label
key={scope}
className="flex items-center gap-2 text-sm cursor-pointer hover:text-foreground"
>
<input
type="checkbox"
className="rounded border-input text-primary focus:ring-primary h-4 w-4"
checked={scopeFilter.includes(scope)}
onChange={(e) =>
handleScopeFilterChange(scope, e.target.checked)
}
/>
{scope}
</option>
</label>
))}
</select>
</div>
</div>
<Button
variant="link"
size="sm"
className="text-xs text-muted-foreground ml-auto"
onClick={() => {
setStatusFilter("all");
setScopeFilter("all");
}}
>
{t("ui.common.reset", "초기화")}
</Button>
<div className="flex justify-end">
<Button
variant="link"
size="sm"
className="text-xs text-muted-foreground p-0 h-auto"
onClick={() => {
setStatusFilter([]);
setScopeFilter([]);
}}
>
{t("ui.common.reset", "초기화")}
</Button>
</div>
</div>
)}
</CardContent>

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Eye, EyeOff, Link2, RefreshCw, Save, Shield } from "lucide-react";
import {
ArrowLeft,
Eye,
EyeOff,
Link2,
RefreshCw,
Save,
Shield,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
@@ -183,29 +191,42 @@ function ClientDetailsPage() {
return (
<div className="space-y-8">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
{t("ui.dev.clients.details.breadcrumb.section", "Apps")}
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.dev.clients.details.breadcrumb.current", "클라이언트 상세")}
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{data.client.name || clientId}</span>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.connection", "Federation")}
</span>
</div>
</nav>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{data.client.name || data.client.id}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.details.subtitle",
"OIDC 자격 증명과 엔드포인트를 관리합니다.",
)}
</p>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to="/clients">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<div>
<h1 className="text-4xl font-black leading-tight tracking-tight">
{data.client.name || data.client.id}
</h1>
<p className="text-muted-foreground">
{t(
"msg.dev.clients.details.subtitle",
"Manage OIDC credentials and endpoints.",
)}
</p>
</div>
</div>
<Badge
variant={data.client.status === "active" ? "success" : "muted"}
variant={data.client.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{data.client.status === "active"
@@ -241,7 +262,7 @@ function ClientDetailsPage() {
<h2 className="text-xl font-bold">
{t(
"ui.dev.clients.details.credentials.title",
"클라이언트 자격 증명",
"Client Credentials",
)}
</h2>
<Card className="glass-panel">

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Save, Shield, Sparkles, Trash2, Upload } from "lucide-react";
import {
ArrowLeft,
Plus,
Save,
Shield,
Sparkles,
Trash2,
Upload,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
@@ -261,22 +269,41 @@ function ClientGeneralPage() {
<header className="space-y-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/clients" className="text-primary hover:underline">
{t("ui.dev.clients.general.breadcrumb.section", "Applications")}
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Link to="/" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.home", "Home")}
</Link>
<span>/</span>
<span className="text-foreground">{displayName}</span>
<Link to="/clients" className="hover:text-primary">
{t("ui.dev.clients.consents.breadcrumb.clients", "Apps")}
</Link>
<span>/</span>
<span>{displayName}</span>
{!isCreate && (
<>
<span>/</span>
<span className="text-foreground font-semibold">
{t("ui.dev.clients.details.tab.settings", "Settings")}
</span>
</>
)}
</nav>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon" asChild>
<Link to={isCreate ? "/clients" : `/clients/${clientId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-black leading-tight">
{isCreate
? t("ui.dev.clients.general.title_create", "Create Client")
: t("ui.dev.clients.general.title_edit", "Client Settings")}
</h1>
</div>
<h1 className="text-3xl font-black leading-tight">
{isCreate
? t("ui.dev.clients.general.title_create", "Create Client")
: t("ui.dev.clients.general.title_edit", "Client Settings")}
</h1>
</div>
{!isCreate && (
<Badge
variant={status === "active" ? "success" : "muted"}
variant={status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{status === "active"
@@ -292,7 +319,7 @@ function ClientGeneralPage() {
to={`/clients/${clientId}`}
className="whitespace-nowrap border-b-2 border-transparent text-muted-foreground hover:text-foreground"
>
{t("ui.dev.clients.details.tab.connection", "Connection")}
{t("ui.dev.clients.details.tab.connection", "Federation")}
</Link>
<Link
to={`/clients/${clientId}/consents`}

View File

@@ -81,7 +81,7 @@ function ClientsPage() {
const stats: StatItem[] = [
{
labelKey: "ui.dev.clients.stats.total",
labelFallback: "총 애플리케이션",
labelFallback: "Total Applications",
value: totalClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
@@ -89,7 +89,7 @@ function ClientsPage() {
},
{
labelKey: "ui.dev.clients.stats.active_sessions",
labelFallback: "활성 세션",
labelFallback: "Active Sessions",
value: activeClients.toString(),
deltaKey: "ui.dev.clients.stats.realtime",
deltaFallback: "Realtime",
@@ -97,7 +97,7 @@ function ClientsPage() {
},
{
labelKey: "ui.dev.clients.stats.auth_failures",
labelFallback: "인증 실패 (24h)",
labelFallback: "Auth Failures (24h)",
value: "0",
deltaKey: "ui.dev.clients.stats.stable",
deltaFallback: "Stable",
@@ -364,7 +364,7 @@ function ClientsPage() {
</TableCell>
<TableCell>
<Badge
variant={client.status === "active" ? "success" : "muted"}
variant={client.status === "active" ? "info" : "muted"}
className="px-3 py-1 text-xs uppercase"
>
{client.status === "active"

View File

@@ -267,7 +267,7 @@ export function ClientFederationPage() {
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
config.status === "active"
? "bg-emerald-500/10 text-emerald-500"
? "bg-blue-500 text-white"
: "bg-muted text-muted-foreground"
}`}
>

View File

@@ -227,25 +227,25 @@ subtitle = "Subtitle"
[msg.dev.clients.details]
copy_client_id = "Client ID copied."
copy_client_secret = "Copy Client Secret"
copy_client_secret = "Client Secret copied."
copy_endpoint = "{{label}} copied."
load_error = "Error loading client: {{error}}"
loading = "Loading client..."
missing_id = "Client ID is required."
redirect_saved = "Redirect URIs saved."
rotate_confirm = "Rotate Confirm"
rotate_error = "Rotate Error"
save_error = "Save Error"
secret_rotated = "Secret Rotated"
rotate_confirm = "Warning: Rotating the Client Secret will invalidate the existing secret immediately.\nConnected applications may experience downtime. Do you want to proceed?"
rotate_error = "Failed to rotate secret: {{error}}"
save_error = "Failed to save: {{error}}"
secret_rotated = "Client Secret has been rotated."
secret_unavailable = "SECRET_NOT_AVAILABLE"
subtitle = "Subtitle"
subtitle = "Manage OIDC credentials and endpoints."
[msg.dev.clients.details.redirect]
description = "Description"
description = "A list of allowed URLs to redirect users to after successful authentication. You can enter multiple URLs separated by commas."
[msg.dev.clients.details.security]
footer = "Footer"
note = "Note"
footer = "We recommend verifying admin session TTL, applying rate limits, and setting up notifications for secret rotation."
note = "Keep endpoints read-only and ensure that secret rotation and copying are tracked in audit logs."
[msg.dev.clients.general]
load_error = "Error loading client: {{error}}"
@@ -301,8 +301,8 @@ dev_scope = "Dev Scope"
hydra_health = "Hydra Health"
[msg.dev.sidebar]
notice = "Notice"
notice_detail = "Notice Detail"
notice = "Developer Console"
notice_detail = "Register and manage client applications."
[msg.info]
saved_success = "Saved successfully."
@@ -978,6 +978,13 @@ active_grants = "Active Grants"
avg_scopes = "Avg. Scopes per User"
total_scopes = "Total Scopes Issued"
[ui.dev.clients.stats]
total = "Total Applications"
active_sessions = "Active Sessions"
auth_failures = "Auth Failures (24h)"
realtime = "Realtime"
stable = "Stable"
[ui.dev.clients.consents.table]
action = "Action"
first_granted = "First Granted"
@@ -990,24 +997,24 @@ user = "User"
[ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = "Current"
section = "Applications"
current = "App Details"
section = "Connected Applications"
[ui.dev.clients.details.credentials]
client_id = "Client ID"
client_secret = "Client Secret"
title = "Title"
title = "Client Credentials"
[ui.dev.clients.details.endpoints]
read_only = "Read Only"
title = "Title"
title = "OIDC Endpoints"
[ui.dev.clients.details.redirect]
callback_label = "Callback Label"
label = "Redirect URIs"
placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback"
save = "Save"
title = "Title"
title = "Redirection Settings"
[ui.dev.clients.details.secret]
hide = "Hide"
@@ -1015,7 +1022,7 @@ rotate = "Rotate"
show = "Show"
[ui.dev.clients.details.security]
title = "Title"
title = "Security Note"
[ui.dev.clients.details.tab]
connection = "Federation"
@@ -1144,6 +1151,7 @@ unknown = "Unknown"
expired = "Session expired"
expiring = "Expiring soon: {{minutes}}m {{seconds}}s left"
remaining = "Expires in: {{minutes}}m {{seconds}}s"
refresh = "Refresh session expiry"
[ui.userfront]
app_title = "Baron SW Portal"

View File

@@ -926,10 +926,10 @@ admin = "Admin"
user = "User"
[ui.common.status]
active = "Active"
blocked = "Blocked"
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "Inactive"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
@@ -979,6 +979,13 @@ active_grants = "활성 권한"
avg_scopes = "사용자당 평균 권한 수"
total_scopes = "전체 부여된 권한 수"
[ui.dev.clients.stats]
total = "총 애플리케이션"
active_sessions = "활성 세션"
auth_failures = "인증 실패 (24h)"
realtime = "실시간"
stable = "안정"
[ui.dev.clients.consents.table]
action = "작업"
first_granted = "최초 동의"
@@ -1020,14 +1027,14 @@ title = "보안 메모"
[ui.dev.clients.details.tab]
connection = "연동 설정"
consents = "Consent & Users"
settings = "Settings"
consents = "동의 및 사용자"
settings = "설정"
[ui.dev.clients.general]
create = "앱 생성"
display_new = "연동 앱 추가"
title_create = "Create Client"
title_edit = "Client Settings"
title_create = "연동 앱 생성"
title_edit = "연동 앱 설정"
[ui.dev.clients.federation]
title = "Identity Federation"
@@ -1145,6 +1152,7 @@ unknown = "확인 불가"
expired = "세션 만료"
expiring = "만료 임박: {{minutes}}분 {{seconds}}초 남음"
remaining = "만료 예정: {{minutes}}분 {{seconds}}초 남음"
refresh = "세션 만료 시간 갱신"
[ui.userfront]
app_title = "Baron SW 포탈"

View File

@@ -990,6 +990,13 @@ active_grants = ""
avg_scopes = ""
total_scopes = ""
[ui.dev.clients.stats]
total = ""
active_sessions = ""
auth_failures = ""
realtime = ""
stable = ""
[ui.dev.clients.consents.table]
action = ""
first_granted = ""
@@ -1156,6 +1163,7 @@ unknown = ""
expired = ""
expiring = ""
remaining = ""
refresh = ""
[ui.userfront]
app_title = ""

View File

@@ -359,8 +359,8 @@ dev_scope = "Dev Scope"
hydra_health = "Hydra Health"
[msg.dev.sidebar]
notice = "Notice"
notice_detail = "Notice Detail"
notice = "Developer Console"
notice_detail = "Register and manage client applications."
[msg.info]
saved_success = "Saved successfully."
@@ -1137,6 +1137,13 @@ active_grants = "Active Grants"
avg_scopes = "Avg. Scopes per User"
total_scopes = "Total Scopes Issued"
[ui.dev.clients.stats]
total = "Total Applications"
active_sessions = "Active Sessions"
auth_failures = "Auth Failures (24h)"
realtime = "Realtime"
stable = "Stable"
[ui.dev.clients.consents.table]
action = "Action"
first_granted = "First Granted"
@@ -1149,24 +1156,24 @@ user = "User"
[ui.dev.clients.details]
[ui.dev.clients.details.breadcrumb]
current = "Current"
section = "Applications"
current = "App Details"
section = "Connected Applications"
[ui.dev.clients.details.credentials]
client_id = "Client ID"
client_secret = "Client Secret"
title = "Title"
title = "Client Credentials"
[ui.dev.clients.details.endpoints]
read_only = "Read Only"
title = "Title"
title = "OIDC Endpoints"
[ui.dev.clients.details.redirect]
callback_label = "Callback Label"
label = "Redirect URIs"
placeholder = "https://your-app.com/callback, http://localhost:3000/auth/callback"
save = "Save"
title = "Title"
title = "Redirection Settings"
[ui.dev.clients.details.secret]
hide = "Hide"
@@ -1174,7 +1181,7 @@ rotate = "Rotate"
show = "Show"
[ui.dev.clients.details.security]
title = "Title"
title = "Security Note"
[ui.dev.clients.details.tab]
connection = "Federation"

View File

@@ -1071,10 +1071,10 @@ admin = "Admin"
user = "User"
[ui.common.status]
active = "Active"
blocked = "Blocked"
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "Inactive"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"
@@ -1137,6 +1137,13 @@ active_grants = "Active Grants"
avg_scopes = "Avg. Scopes per User"
total_scopes = "Total Scopes Issued"
[ui.dev.clients.stats]
total = "총 애플리케이션"
active_sessions = "활성 세션"
auth_failures = "인증 실패 (24h)"
realtime = "실시간"
stable = "안정"
[ui.dev.clients.consents.table]
action = "Action"
first_granted = "First Granted"
@@ -1178,14 +1185,14 @@ title = "보안 메모"
[ui.dev.clients.details.tab]
connection = "연동 설정"
consents = "Consent & Users"
settings = "Settings"
consents = "동의 및 사용자"
settings = "설정"
[ui.dev.clients.general]
create = "앱 생성"
display_new = "연동 앱 추가"
title_create = "Create Client"
title_edit = "Client Settings"
title_create = "연동 앱 생성"
title_edit = "연동 앱 설정"
[ui.dev.clients.federation]
title = "Identity Federation"

View File

@@ -999,6 +999,13 @@ active_grants = ""
avg_scopes = ""
total_scopes = ""
[ui.dev.clients.stats]
total = ""
active_sessions = ""
auth_failures = ""
realtime = ""
stable = ""
[ui.dev.clients.consents.table]
action = ""
first_granted = ""

View File

@@ -0,0 +1,81 @@
import { expect, test, type Page, type Route } from '@playwright/test';
async function mockUserfrontApisForRepro(
page: Page,
options: { sessionStatus: number } = { sessionStatus: 401 },
): Promise<void> {
await page.route('**/api/v1/**', async (route: Route) => {
const requestUrl = new URL(route.request().url());
const path = requestUrl.pathname;
if (path.endsWith('/api/v1/user/me')) {
await route.fulfill({
status: options.sessionStatus,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
return;
}
// Default mock for other APIs
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({}),
});
});
}
test.describe('Issue #345 Reproduction (Log-based Validation)', () => {
test('비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다', async ({ page }) => {
const logs: string[] = [];
page.on('console', msg => {
const text = msg.text();
logs.push(text);
console.log(`[Browser] ${text}`);
});
const requests: string[] = [];
page.on('request', request => {
if (request.isNavigationRequest()) {
requests.push(request.url());
}
});
await mockUserfrontApisForRepro(page, { sessionStatus: 401 });
const targetUrl = '/ko/signin?login_challenge=repro_challenge_12345';
await page.goto(targetUrl);
// WASM 앱 로딩 및 로직 실행 대기
await page.waitForTimeout(7000);
const currentUrl = page.url();
const signinNavigations = requests.filter(url => url.includes('/signin'));
// [검증 1] URL 유지 확인
expect(currentUrl).toContain('login_challenge=repro_challenge_12345');
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
expect(signinNavigations.length).toBeLessThanOrEqual(1);
// [검증 3] 핵심 로직 로그 확인 (성공의 결정적 증거)
// 이전에는 여기서 Exception이 발생했으나, 이제는 아래 로그가 찍혀야 함
const hasSuccessLog = logs.some(log =>
log.includes('[Auth] OIDC auto-accept: No active session (status: 401)')
);
expect(hasSuccessLog).toBe(true);
console.log('✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.');
});
});

View File

@@ -347,10 +347,10 @@ admin = "Admin"
user = "User"
[ui.common.status]
active = "Active"
blocked = "Blocked"
active = "활성"
blocked = "차단됨"
failure = "실패"
inactive = "Inactive"
inactive = "비활성"
ok = "정상"
pending = "준비 중"
success = "성공"

View File

@@ -161,7 +161,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final provider = pendingProvider ?? AuthTokenStore.getProvider() ?? 'ory';
try {
await AuthProxyService.checkCookieSession();
final status = await AuthProxyService.getSessionStatus(useCookie: true);
if (status != 200) {
debugPrint(
"[Auth] Cookie session check: No active session (status: $status)",
);
return;
}
if (!shouldPromoteCookieSession(
currentToken: AuthTokenStore.getToken(),
loginChallenge: loginChallenge,
@@ -242,11 +249,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
try {
await AuthProxyService.checkCookieSession();
AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect();
// 401 응답은 세션이 없는 정상적인 상태이므로 예외로 처리하지 않고 우아하게 중단합니다.
final status = await AuthProxyService.getSessionStatus(useCookie: true);
if (status == 200) {
AuthTokenStore.setCookieMode(
provider: AuthTokenStore.getProvider() ?? 'ory',
);
await _acceptOidcLoginAndRedirect();
} else {
debugPrint(
"[Auth] OIDC auto-accept: No active session (status: $status)",
);
}
} catch (e) {
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
}

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.1"
version: "1.4.0"
cli_config:
dependency: transitive
description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker:
dependency: transitive
description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive
description:
name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.18"
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.13.0"
version: "0.11.1"
meta:
dependency: transitive
description:
@@ -645,26 +653,26 @@ packages:
dependency: transitive
description:
name: test
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a"
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.29.0"
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636"
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.9"
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943"
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.15"
version: "0.6.12"
toml:
dependency: "direct main"
description: