forked from baron/baron-sso
Merge commit 'd50e583c30dae4fe46c478e153aa28c144dfc6d3' into feature/adminfront
This commit is contained in:
4
Makefile
4
Makefile
@@ -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; \
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 포탈"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -361,8 +361,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."
|
||||
@@ -1152,6 +1152,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"
|
||||
@@ -1164,24 +1171,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"
|
||||
@@ -1189,7 +1196,7 @@ rotate = "Rotate"
|
||||
show = "Show"
|
||||
|
||||
[ui.dev.clients.details.security]
|
||||
title = "Title"
|
||||
title = "Security Note"
|
||||
|
||||
[ui.dev.clients.details.tab]
|
||||
connection = "Federation"
|
||||
|
||||
@@ -1080,10 +1080,10 @@ admin = "Admin"
|
||||
user = "User"
|
||||
|
||||
[ui.common.status]
|
||||
active = "Active"
|
||||
blocked = "Blocked"
|
||||
active = "활성"
|
||||
blocked = "차단됨"
|
||||
failure = "실패"
|
||||
inactive = "Inactive"
|
||||
inactive = "비활성"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
success = "성공"
|
||||
@@ -1152,6 +1152,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"
|
||||
@@ -1193,14 +1200,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"
|
||||
|
||||
@@ -1034,6 +1034,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 = ""
|
||||
|
||||
81
userfront-e2e/tests/oidc-login-challenge.spec.ts
Normal file
81
userfront-e2e/tests/oidc-login-challenge.spec.ts
Normal 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('✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.');
|
||||
});
|
||||
});
|
||||
@@ -349,10 +349,10 @@ admin = "Admin"
|
||||
user = "User"
|
||||
|
||||
[ui.common.status]
|
||||
active = "Active"
|
||||
blocked = "Blocked"
|
||||
active = "활성"
|
||||
blocked = "차단됨"
|
||||
failure = "실패"
|
||||
inactive = "Inactive"
|
||||
inactive = "비활성"
|
||||
ok = "정상"
|
||||
pending = "준비 중"
|
||||
success = "성공"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user